Search code examples
python-3.xpyqt5qthread

How to use QThread in both gui and non-gui application in Python?


I am writing a fuzzy search application which finds words in text even if words have mistakes. I wrote gui form but it freezed because of heavy calculation. So I created class inherited from QThread and emitted signals from it to gui so that progress bar started to work and gui form wasn't freezed anymore. BUT I should also create console version of this application where I don't need gui form but I need methods that written in class inherited from QThread. But it's impossible without using PyQT library which strange to use in console version. So I don't know how to solve this problem. My teacher recommended to use threading but I didn't find how to emit signals from Thread class as I colud do in QThread.

That's a QThread class

import text_methods
from PyQt5.QtCore import pyqtSignal, QThread


class FuzzySearch(QThread):
    sig_words_count = pyqtSignal(int)
    sig_step = pyqtSignal(int)
    sig_done = pyqtSignal(bool)
    sig_insertions = pyqtSignal(str)
    sig_insertions_indexes = pyqtSignal(list)

    def __init__(self, text, words, case_sensitive):
        super().__init__()
        self.text = text
        self.words = words
        self.case_sensitive = case_sensitive
        self.insertions_indexes = {}
        self.text_dict = {}

    def run(self):
        self.get_insertions_info(self.text, self.words)

    def find_insertions_of_word(self, word, word_number):
        word_insertions = {}
        for textword in self.text_dict.keys():
            if text_methods.is_optimal_distance(word, textword):
                word_insertions[textword] = self.text_dict[textword]
                for index in self.text_dict[textword]:
                    self.insertions_indexes[index] = index + len(textword)
        self.sig_step.emit(word_number)
        return word_insertions

    '''Get information about insertions of words in the text'''
    def find_insertions(self, text, words):
        word_number = 1
        insertions = {}
        self.text_dict = text_methods.transform_text_to_dict(text, self.case_sensitive)
        words_list = text_methods.transform_words_to_list(words, self.case_sensitive)
        self.sig_words_count.emit(len(words_list))
        for word in words_list:
            print(word_number)
            insertions[word] = self.find_insertions_of_word(word, word_number)
            word_number += 1
        self.insertions_indexes = sorted(self.insertions_indexes.items())
        return insertions

    '''Get information about insertions of words in the text in special format'''
    def get_insertions_info(self, text, words):
        insertions = self.find_insertions(text, words)
        insertions_info = ''
        for word in insertions.keys():
            insertions_info += 'Вы искали слово "' + word + '"\n'
            if len(insertions[word]) == 0:
                insertions_info += '  По этому запросу не было найдено слов\n'
            else:
                insertions_info += '  По этому запросу были найдены слова:\n'
                for textword in insertions[word].keys():
                    insertions_info += '   "' + textword + '" на позициях: '
                    insertions_info += ", ".join([str(i) for i in insertions[word][textword]])
                    insertions_info += '\n'
        self.sig_done.emit(True)
        self.sig_insertions.emit(insertions_info)
        self.sig_insertions_indexes.emit(self.insertions_indexes)
        self.quit()

As you see there are a lot of emitted signals that I transfer to gui module where they connect to slots in class FindButton in method find_insertions:

from PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QApplication, QWidget,\
                            QLabel, QPushButton, QTextEdit, QFileDialog,\
                            QMessageBox, QProgressBar, QCheckBox
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QFont
from fuzzysearch import FuzzySearch
import sys


class OpenButton(QPushButton):
    def __init__(self, name, font, textedit):
        super().__init__(name, font=font)
        self.textedit = textedit
        self.clicked.connect(self.open_dialog)

    def open_dialog(self):
        fname = QFileDialog.getOpenFileName(self, 'Open file', '/home')
        if fname[0]:
            with open(fname[0], 'r') as f:
                data = f.read()
                self.textedit.setText(data)


class FindButton(QPushButton):
    def __init__(self, name, font, text, words, result, window):
        super().__init__(name, font=font)
        self.window = window
        self.textedit = text
        self.wordsedit = words
        self.resultedit = result
        self.checkbox = window.case_sensitive_checkbox
        self.clicked.connect(self.find_insertions)

    def find_insertions(self):
        text = self.textedit.toPlainText()
        words = self.wordsedit.toPlainText()
        if text == '':
            QMessageBox.information(self, 'Нет текста',
                                    'Текст не был введен. \nВведите текст.')
        elif words == '':
            QMessageBox.information(self, 'Нет слов',
                                    'Слова не были введены. \nВведите слова через запятую.')
        else:
            self.setDisabled(True)
            self.text_editor = TextEditor(text, self.textedit)
            self.fuzzy_search = FuzzySearch(text, words, self.checkbox.checkState())
            self.fuzzy_search.sig_words_count.connect(self.window.progress_bar.setMaximum)
            self.fuzzy_search.sig_step.connect(self.window.progress_bar.setValue)
            self.fuzzy_search.sig_done.connect(self.setEnabled)
            self.fuzzy_search.sig_insertions.connect(self.resultedit.setText)
            self.fuzzy_search.sig_insertions_indexes.connect(self.text_editor.mark)
            self.fuzzy_search.start()


class TextEditor:
    def __init__(self, text, textedit):
        self.text = text
        self.textedit = textedit

    def mark(self, to_mark):
        self.textedit.clear()
        current_index = 0
        for item in to_mark:
            self.write_not_marked_text(self.text[current_index:item[0]])
            self.write_marked_text(self.text[item[0]:item[1]])
            current_index = item[1]
        self.write_not_marked_text(self.text[current_index:])

    def write_not_marked_text(self, text):
        font = QFont("Times", 10)
        font.setItalic(False)
        font.setBold(False)
        self.textedit.setCurrentFont(font)
        self.textedit.setTextColor(Qt.black)
        self.textedit.insertPlainText(text)

    def write_marked_text(self, text):
        font = QFont("Times", 10)
        font.setItalic(True)
        font.setBold(True)
        self.textedit.setCurrentFont(font)
        self.textedit.setTextColor(Qt.red)
        self.textedit.insertPlainText(text)


class Window(QWidget):
    def __init__(self, font):
        super().__init__()
        self.standard_font = font
        self.text_edit_font = QFont("Times", 10)

        text_label = QLabel("Введите или откройте текст",
                            font=self.standard_font)
        words_label = QLabel("Введите или откройте слова (через запятую)",
                             font=self.standard_font)
        result_label = QLabel("Результат",
                              font=self.standard_font)

        text_edit = QTextEdit(font=self.text_edit_font)
        words_edit = QTextEdit(font=self.text_edit_font)
        result_edit = QTextEdit(font=self.text_edit_font)

        self.case_sensitive_checkbox = QCheckBox('Учитывать регистр')
        self.case_sensitive_checkbox.setFont(self.standard_font)

        self.progress_bar = QProgressBar()
        self.progress_bar.setValue(0)

        open_btn1 = OpenButton("Открыть", self.standard_font, text_edit)
        open_btn2 = OpenButton("Открыть", self.standard_font, words_edit)
        find_btn = FindButton("Найти слова в тексте", self.standard_font,
                              text_edit, words_edit, result_edit, self)


        text_label_box = QHBoxLayout()
        text_label_box.addWidget(text_label, alignment=Qt.AlignLeft)
        text_label_box.addWidget(open_btn1, alignment=Qt.AlignRight)

        words_label_box = QHBoxLayout()
        words_label_box.addWidget(words_label, alignment=Qt.AlignLeft)
        words_label_box.addWidget(open_btn2, alignment=Qt.AlignRight)

        words_box = QVBoxLayout()
        words_box.addLayout(words_label_box)
        words_box.addWidget(words_edit)

        result_box = QVBoxLayout()
        result_box.addWidget(result_label, alignment=Qt.AlignLeft)
        result_box.addWidget(result_edit)

        bottom_box = QHBoxLayout()
        bottom_box.addLayout(words_box)
        bottom_box.addLayout(result_box)

        find_and_progress_box = QHBoxLayout()
        find_and_progress_box.addWidget(find_btn, alignment=Qt.AlignLeft)
        find_and_progress_box.addWidget(self.case_sensitive_checkbox)
        find_and_progress_box.addWidget(self.progress_bar)

        main_box = QVBoxLayout()
        main_box.addLayout(text_label_box)
        main_box.addWidget(text_edit)
        main_box.addLayout(bottom_box)
        main_box.addLayout(find_and_progress_box)

        self.setLayout(main_box)

        self.setGeometry(300, 300, 1100, 700)
        self.setWindowTitle('Нечеткий поиск')
        self.show()


def start_application():
    app = QApplication(sys.argv)
    w = Window(QFont("Times", 12))
    sys.exit(app.exec_())

And it works perfectly. But it will not work in console version because without QEventLoop QThread will not work:

import fuzzysearch


class ConsoleVersion():
    def __init__(self, text, words):
        self.text = text
        self.words = words

    def search_words_in_text(self):
        with self.text:
            with self.words:
                self.f = fuzzysearch.FuzzySearch(self.text.read(), self.words.read(), False)
                self.f.sig_insertions.connect(self.get_insertions)
                self.f.start()


    def get_insertions(self, insertions):
        print(insertions)

and in main file I wrote parsing arguments and choise between two versions

import argparse
import gui
import console_version

def parse_args():
    parser = argparse.ArgumentParser(description='Fuzzy search in text')
    parser.add_argument('-g', '--graphics', help='graphical version', action='store_true')
    parser.add_argument('-c', '--console', help='console version', nargs=2, type=argparse.FileType('r'), metavar=('TEXTFILE', 'WORDSFILE'))
    return parser.parse_args()


if __name__ == '__main__':
    args = parse_args()
    if args.graphics:
        gui.start_application()
    if args.console:
        cv = console_version.ConsoleVersion(args.console[0], args.console[1])
        cv.search_words_in_text()

and module text_methods:

from re import split, sub


def transform_text_to_dict(text, case_sensitive):
    text_dict = {}
    index = 0
    if case_sensitive:
        splitted_text = split("[^'а-яА-ЯA-Za-z0-9_-]", text)
    else:
        splitted_text = split("[^'а-яА-ЯA-Za-z0-9_-]", text.lower())
    for element in splitted_text:
        if element not in text_dict:
            text_dict[element] = []
        text_dict[element].append(index)
        index += len(element) + 1
    return text_dict


def transform_words_to_list(words, case_sensitive):
    words = sub("^\s+|\n|\r|\s+$", '', words)
    if case_sensitive:
        return split(' *, *', words)
    else:
        return split(' *, *', words.lower())


'''Damerau-Levenstein'''
def find_distance(word1: str, word2: str):
    len1, len2 = len(word1), len(word2)
    if len1 > len2:
        word1, word2 = word2, word1
        len1, len2 = len2, len1
    current_row = range(len1 + 1)
    previous_row = range(len1 + 1)
    pre_previous_row = range(len1 + 1)
    for i in range(1, len2 + 1):
        if i == 1:
            previous_row, current_row = current_row, [i] + [0] * len1
        else:
            pre_previous_row, previous_row, current_row = previous_row, current_row, [i] + [0] * len1
        for j in range(1, len1 + 1):
            add = previous_row[j] + 1
            delete = current_row[j - 1] + 1
            change = previous_row[j - 1]
            if word1[j - 1] != word2[i - 1]:
                change += 1
            if word1[j - 1] == word2[i - 2] and word1[j - 2] == word2[i - 1]:
                transpose = pre_previous_row[j - 2] + 1
                current_row[j] = min(add, delete, change, transpose)
            else:
                current_row[j] = min(add, delete, change)
    return current_row[len1]


def is_optimal_distance(word1 : str, word2 : str):
    distance = find_distance(word1, word2)
    l = min(len(word1), len(word2))
    return distance <= l // 4

so what can you advise me?


Solution

  • Qt so that to handle the tasks always needs a loop that is created internally for it one must construct an object of the type QCoreApplication, QGuiApplication or QApplication, this needs it for example for this case for QThread that is not a thread but a thread handler for that is monitoring the state of the thread, if you do not place the application is closed immediately since the run method is not executed in the main thread.

    if args.console: 
        app = QCoreApplication(sys.argv) 
        cv = console_version.ConsoleVersion(args.console[0], args.console[1]) 
        cv.search_words_in_text() 
        sys.exit(app.exec_())