diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index e4c72c835e..0420e6413c 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -73,6 +73,8 @@ class CommandAddonManager: "showmacro_worker", "macro_worker", "install_worker", "update_metadata_cache_worker", "update_all_worker"] + lock = threading.Lock() + def GetResources(self) -> Dict[str,str]: return {"Pixmap": "AddonManager", "MenuText": QT_TRANSLATE_NOOP("Std_AddonMgr", "&Addon manager"), @@ -113,6 +115,7 @@ class CommandAddonManager: # cleanup the leftovers from previous runs self.macro_repo_dir = tempfile.mkdtemp() self.packages_with_updates = [] + self.startup_sequence = [] self.addon_removed = False self.cleanup_workers() @@ -189,30 +192,25 @@ class CommandAddonManager: self.dialog.move(mw.frameGeometry().topLeft() + mw.rect().center() - self.dialog.rect().center()) # set info for the progress bar: - self.number_of_progress_regions = 4 - self.current_progress_region = 0 self.dialog.progressBar.setMaximum (100) - # populate the table - self.populate_packages_table() + # begin populating the table in a set of sub-threads + self.startup() # set the label text to start with - self.show_information(translate("AddonInstaller", "Loading addon information")) + self.show_information(translate("AddonsInstaller", "Loading addon information")) # rock 'n roll!!! self.dialog.exec_() - def cleanup_workers(self) -> None: - """ Ensure that no workers are running by explicitly asking them to stop, and terminating them if they don't """ + def cleanup_workers(self, wait=False) -> None: + """ Ensure that no workers are running by explicitly asking them to stop and waiting for them until they do """ for worker in self.workers: if hasattr(self, worker): thread = getattr(self, worker) if thread: if not thread.isFinished(): thread.requestInterruption() - thread.wait(QtCore.QDeadlineTimer(250)) - if not thread.isFinished(): - thread.terminate() # Highly undesirable, hopefully the thread obeyed the request to interrupt thread.wait() def wait_on_other_workers(self) -> None: @@ -235,6 +233,7 @@ class CommandAddonManager: # ensure all threads are finished before closing oktoclose = True + self.startup_sequence = [] for worker in self.workers: if hasattr(self, worker): thread = getattr(self, worker) @@ -248,10 +247,7 @@ class CommandAddonManager: if hasattr(self, worker): thread = getattr(self, worker) if thread: - thread.wait(QtCore.QDeadlineTimer(50)) # 50ms to wrap up whatever loop iteration it was on - if not thread.isFinished(): - thread.terminate() # Highly undesirable, hopefully the thread obeyed the request to interrupt - thread.wait() + thread.wait() # all threads have finished if oktoclose: @@ -284,7 +280,7 @@ class CommandAddonManager: FreeCAD.Console.PrintWarning("Could not terminate sub-threads in Addon Manager.\n") self.cleanup_workers() - def populate_packages_table(self) -> None: + def startup(self) -> None: """ Downloads the available packages listings and populates the table This proceeds in four stages: first, the main GitHub repository is queried for a list of possible @@ -305,13 +301,36 @@ class CommandAddonManager: """ + # Each function in this list is expected to launch a thread and connect its completion signal + # to self.do_next_startup_phase + self.startup_sequence = [self.populate_packages_table, + self.populate_macros, + self.update_metadata_cache, + self.check_updates] + self.current_progress_region = 0 + self.number_of_progress_regions = len(self.startup_sequence) + self.do_next_startup_phase() + + def do_next_startup_phase(self) -> None: + """ Pop the top item in self.startup_sequence off the list and run it """ + + if (len(self.startup_sequence) > 0): + phase_runner = self.startup_sequence.pop(0) + self.current_progress_region += 1 + phase_runner() + else: + self.hide_progress_widgets() + self.dialog.tablePackages.setEnabled(True) + self.dialog.lineEditFilter.setFocus() + + def populate_packages_table(self) -> None: self.item_model.clear() self.current_progress_region += 1 self.update_worker = UpdateWorker() self.update_worker.status_message.connect(self.show_information) self.update_worker.addon_repo.connect(self.add_addon_repo) self.update_progress_bar(10,100) - self.update_worker.done.connect(self.populate_macros) # Link to step 2 + self.update_worker.done.connect(self.do_next_startup_phase) # Link to step 2 self.update_worker.start() def populate_macros(self) -> None: @@ -320,8 +339,7 @@ class CommandAddonManager: self.macro_worker.status_message_signal.connect(self.show_information) self.macro_worker.progress_made.connect(self.update_progress_bar) self.macro_worker.add_macro_signal.connect(self.add_addon_repo) - self.macro_worker.done.connect(self.update_metadata_cache) # Link to step 3 - self.macro_worker.done.connect(lambda : self.dialog.tablePackages.setEnabled(True)) + self.macro_worker.done.connect(self.do_next_startup_phase) # Link to step 3 self.macro_worker.start() def update_metadata_cache(self) -> None: @@ -330,23 +348,24 @@ class CommandAddonManager: if pref.GetBool("AutoFetchMetadata", True): self.update_metadata_cache_worker = UpdateMetadataCacheWorker(self.item_model.repos) self.update_metadata_cache_worker.status_message.connect(self.show_information) - self.update_metadata_cache_worker.done.connect(self.check_updates) # Link to step 4 + self.update_metadata_cache_worker.done.connect(self.do_next_startup_phase) # Link to step 4 self.update_metadata_cache_worker.progress_made.connect(self.update_progress_bar) self.update_metadata_cache_worker.package_updated.connect(self.on_package_updated) self.update_metadata_cache_worker.start() else: - self.check_updates() + self.do_next_startup_phase() def on_package_updated(self, repo:AddonManagerRepo) -> None: """Called when the named package has either new metadata or a new icon (or both)""" - cache_path = os.path.join(FreeCAD.getUserAppDataDir(), "AddonManager", "PackageMetadata", repo.name) - icon_filename = repo.metadata.Icon - icon_path = os.path.join(cache_path, icon_filename) - if os.path.isfile(icon_path): - addonicon = QtGui.QIcon(icon_path) - repo.icon = addonicon - self.item_model.reload_item(repo) + with self.lock: + cache_path = os.path.join(FreeCAD.getUserAppDataDir(), "AddonManager", "PackageMetadata", repo.name) + icon_filename = repo.metadata.Icon + icon_path = os.path.join(cache_path, icon_filename) + if os.path.isfile(icon_path): + addonicon = QtGui.QIcon(icon_path) + repo.icon = addonicon + self.item_model.reload_item(repo) def check_updates(self) -> None: @@ -356,16 +375,18 @@ class CommandAddonManager: pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") autocheck = pref.GetBool("AutoCheck", False) if not autocheck: + self.do_next_startup_phase() return if not self.packages_with_updates: if hasattr(self, "check_worker"): thread = self.check_worker if thread: if not thread.isFinished(): + self.do_next_startup_phase() return self.dialog.buttonUpdateAll.setText(translate("AddonsInstaller", "Checking for updates...")) self.check_worker = CheckWorkbenchesForUpdatesWorker(self.item_model.repos) - self.check_worker.done.connect(self.hide_progress_widgets) + self.check_worker.done.connect(self.do_next_startup_phase) self.check_worker.progress_made.connect(self.update_progress_bar) self.check_worker.update_status.connect(self.status_updated) self.check_worker.start() @@ -440,9 +461,9 @@ class CommandAddonManager: if not current.isValid(): self.selected_repo = None return - source_selection = self.item_filter.mapToSource (current) self.selected_repo = self.item_model.repos[source_selection.row()] + self.dialog.description.clear() if self.selected_repo.repo_type == AddonManagerRepo.RepoType.MACRO: self.show_macro(self.selected_repo) self.dialog.buttonExecute.show() @@ -832,11 +853,11 @@ class CommandAddonManager: if text_filter: test_regex = QtCore.QRegularExpression(text_filter) if test_regex.isValid(): - self.dialog.labelFilterValidity.setToolTip(translate("AddonInstaller","Filter is valid")) + self.dialog.labelFilterValidity.setToolTip(translate("AddonsInstaller","Filter is valid")) icon = QtGui.QIcon.fromTheme("ok", QtGui.QIcon(":/icons/edit_OK.svg")) self.dialog.labelFilterValidity.setPixmap(icon.pixmap(16,16)) else: - self.dialog.labelFilterValidity.setToolTip(translate("AddonInstaller","Filter regular expression is invalid")) + self.dialog.labelFilterValidity.setToolTip(translate("AddonsInstaller","Filter regular expression is invalid")) icon = QtGui.QIcon.fromTheme("cancel", QtGui.QIcon(":/icons/edit_Cancel.svg")) self.dialog.labelFilterValidity.setPixmap(icon.pixmap(16,16)) self.dialog.labelFilterValidity.show() diff --git a/src/Mod/AddonManager/AddonManagerRepo.py b/src/Mod/AddonManager/AddonManagerRepo.py index 44b60fa67d..79a22e130c 100644 --- a/src/Mod/AddonManager/AddonManagerRepo.py +++ b/src/Mod/AddonManager/AddonManagerRepo.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- #*************************************************************************** #* * #* Copyright (c) 2021 Chris Hennes * @@ -45,6 +44,9 @@ class AddonManagerRepo: elif self.value == 3: return "Package" + def __int__(self) -> int : + return self.value + class UpdateStatus(Enum): NOT_INSTALLED = 0 UNCHECKED = 1 @@ -161,4 +163,4 @@ class AddonManagerRepo: store = os.path.join(FreeCAD.getUserAppDataDir(), "AddonManager", "PackageMetadata") self.cached_icon_filename = os.path.join(store, self.name, "cached_icon"+file_extension) - return self.cached_icon_filename \ No newline at end of file + return self.cached_icon_filename diff --git a/src/Mod/AddonManager/addonmanager_metadata.py b/src/Mod/AddonManager/addonmanager_metadata.py index 94e290adeb..f72414866a 100644 --- a/src/Mod/AddonManager/addonmanager_metadata.py +++ b/src/Mod/AddonManager/addonmanager_metadata.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- #*************************************************************************** #* * #* Copyright (c) 2021 Chris Hennes * @@ -23,7 +22,6 @@ import FreeCAD -import git import tempfile import os import hashlib @@ -67,6 +65,7 @@ class MetadataDownloadWorker(QObject): self.fetch_task = network_manager.get(self.request) self.fetch_task.finished.connect(self.resolve_fetch) self.fetch_task.redirected.connect(self.on_redirect) + self.fetch_task.sslErrors.connect(self.on_ssl_error) def abort(self): self.fetch_task.abort() @@ -75,6 +74,12 @@ class MetadataDownloadWorker(QObject): # For now just blindly follow all redirects self.fetch_task.redirectAllowed.emit() + def on_ssl_error(self, reply, errors): + FreeCAD.Console.PrintWarning(f"Error with encrypted connection:\n") + FreeCAD.Console.PrintWarning(reply) + for error in errors: + FreeCAD.Console.PrintWarning(error) + def resolve_fetch(self): "Called when the data fetch completed, either with an error, or if it found the metadata file" if self.fetch_task.error() == QtNetwork.QNetworkReply.NetworkError.NoError: @@ -104,11 +109,15 @@ class MetadataDownloadWorker(QObject): # There is no local copy yet, so we definitely have to update # the cache self.update_local_copy(new_xml) + elif self.fetch_task.error() == QtNetwork.QNetworkReply.NetworkError.ContentNotFoundError: + pass + else: + FreeCAD.Console.PrintWarning(f"Failed to connect to {self.url}:\n {self.fetch_task.error()}\n") def update_local_copy(self, new_xml): # We have to update the local copy of the metadata file and re-download # the icon file - + name = self.repo.name repo_url = self.repo.url package_cache_directory = os.path.join(self.store, name) @@ -116,7 +125,7 @@ class MetadataDownloadWorker(QObject): os.makedirs(package_cache_directory) new_xml_file = os.path.join(package_cache_directory, "package.xml") with open(new_xml_file, "wb") as f: - f.write(new_xml) + f.write(new_xml.data()) metadata = FreeCAD.Metadata(new_xml_file) self.repo.metadata = metadata self.repo.repo_type = AddonManagerRepo.RepoType.PACKAGE diff --git a/src/Mod/AddonManager/addonmanager_utilities.py b/src/Mod/AddonManager/addonmanager_utilities.py index c74b3cfe06..0ac073e449 100644 --- a/src/Mod/AddonManager/addonmanager_utilities.py +++ b/src/Mod/AddonManager/addonmanager_utilities.py @@ -203,10 +203,13 @@ def construct_git_url(repo, filename): parsed_url = urlparse(repo.url) if parsed_url.netloc == "github.com" or parsed_url.netloc == "framagit.com": return f"{repo.url}/raw/{repo.branch}/{filename}" - elif parsed_url.netloc == "gitlab.com" or parsed_url.netloc == "salsa.debian.org": + elif parsed_url.netloc == "gitlab.com": + return f"{repo.url}/-/raw/{repo.branch}/{filename}" + elif parsed_url.netloc == "salsa.debian.org": + # e.g. https://salsa.debian.org/joha2/pyrate/-/raw/master/package.xml return f"{repo.url}/-/raw/{repo.branch}/{filename}" else: - FreeCAD.Console.PrintLog("Debug: addonmanager_utilities.construct_git_url: Unknown git host:", parsed_url.netloc) + FreeCAD.Console.PrintLog("Debug: addonmanager_utilities.construct_git_url: Unknown git host:" + parsed_url.netloc) return None def get_readme_url(repo): diff --git a/src/Mod/AddonManager/addonmanager_workers.py b/src/Mod/AddonManager/addonmanager_workers.py index eb72968390..4712e3c94d 100644 --- a/src/Mod/AddonManager/addonmanager_workers.py +++ b/src/Mod/AddonManager/addonmanager_workers.py @@ -163,20 +163,6 @@ class UpdateWorker(QtCore.QThread): self.addon_repo.emit(cached_package) package_names.append(name) - u = utils.urlopen("https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/.gitmodules") - if not u: - self.done.emit() - self.stop = True - return - p = u.read() - if isinstance(p, bytes): - p = p.decode("utf-8") - u.close() - p = re.findall((r'(?m)\[submodule\s*"(?P.*)"\]\s*' - r"path\s*=\s*(?P.+)\s*" - r"url\s*=\s*(?Phttps?://.*)\s*" - r"(branch\s*=\s*(?P.*)\s*)?"), p) - # querying custom addons addon_list = (FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") .GetString("CustomRepositories", "").split("\n")) @@ -207,6 +193,19 @@ class UpdateWorker(QtCore.QThread): self.addon_repo.emit(AddonManagerRepo(name, addon["url"], state, addon["branch"])) # querying official addons + u = utils.urlopen("https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/.gitmodules") + if not u: + self.done.emit() + self.stop = True + return + p = u.read() + if isinstance(p, bytes): + p = p.decode("utf-8") + u.close() + p = re.findall((r'(?m)\[submodule\s*"(?P.*)"\]\s*' + r"path\s*=\s*(?P.+)\s*" + r"url\s*=\s*(?Phttps?://.*)\s*" + r"(branch\s*=\s*(?P.*)\s*)?"), p) for name, path, url, _, branch in p: if self.current_thread.isInterruptionRequested(): return @@ -233,7 +232,9 @@ class UpdateWorker(QtCore.QThread): class CheckWorkbenchesForUpdatesWorker(QtCore.QThread): """This worker checks for available updates for all workbenches""" - update_status = QtCore.Signal(AddonManagerRepo, AddonManagerRepo.UpdateStatus) + # Emitting an Enum fails on Ubuntu 20.04, so use an int instead + #update_status = QtCore.Signal(AddonManagerRepo, AddonManagerRepo.UpdateStatus) + update_status = QtCore.Signal(AddonManagerRepo, int) progress_made = QtCore.Signal(int, int) done = QtCore.Signal() @@ -245,6 +246,7 @@ class CheckWorkbenchesForUpdatesWorker(QtCore.QThread): def run(self): if NOGIT or not have_git: + self.done.emit() self.stop = True return self.current_thread = QtCore.QThread.currentThread() @@ -271,6 +273,8 @@ class CheckWorkbenchesForUpdatesWorker(QtCore.QThread): self.done.emit() def check_workbench(self, wb): + if not have_git or NOGIT: + return clonedir = self.moddir + os.sep + wb.name if os.path.exists(clonedir): # mark as already installed AND already checked for updates @@ -399,7 +403,7 @@ class FillMacroListWorker(QtCore.QThread): https://github.com/FreeCAD/FreeCAD-macros.git """ - if not have_git: + if not have_git or NOGIT: self.status_message_signal.emit("GitPython not installed! Cannot retrieve macros from Git") FreeCAD.Console.PrintWarning(translate("AddonsInstaller", "GitPython not installed! Cannot retrieve macros from git") + "\n") @@ -558,6 +562,8 @@ class ShowWorker(QtCore.QThread): if not desc: desc = "Unable to retrieve addon description" self.repo.description = desc + if QtCore.QThread.currentThread().isInterruptionRequested(): + return self.addon_repos.emit(self.repo) # Addon is installed so lets check if it has an update if self.repo.update_status == AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE: @@ -610,8 +616,10 @@ class ShowWorker(QtCore.QThread): message += self.repo.url + '">' + self.repo.url + "" self.repo.update_status = AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE # Let the user know the install path for this addon - message += "
" + translate("AddonInstaller", "Installed location") + ": " + message += "
" + translate("AddonsInstaller", "Installed location") + ": " message += FreeCAD.getUserAppDataDir() + os.sep + "Mod" + os.sep + self.repo.name + if QtCore.QThread.currentThread().isInterruptionRequested(): + return self.addon_repos.emit(self.repo) elif self.repo.update_status == AddonManagerRepo.UpdateStatus.PENDING_RESTART: message = """ @@ -622,7 +630,7 @@ class ShowWorker(QtCore.QThread): message += translate("AddonsInstaller", "This addon has been updated, a restart is now required before it can be used.") message += "

" + desc + '

Addon repository: ' + self.repo.url + "" - message += "
" + translate("AddonInstaller", "Installed location") + ": " + message += "
" + translate("AddonsInstaller", "Installed location") + ": " message += FreeCAD.getUserAppDataDir() + os.sep + "Mod" + os.sep + self.repo.name elif self.repo.update_status == AddonManagerRepo.UpdateStatus.NO_UPDATE_AVAILABLE: message = """ @@ -634,7 +642,7 @@ class ShowWorker(QtCore.QThread): message += "

" + desc message += '

Addon repository: ' + self.repo.url + "" - message += "
" + translate("AddonInstaller", "Installed location") + ": " + message += "
" + translate("AddonsInstaller", "Installed location") + ": " message += FreeCAD.getUserAppDataDir() + os.sep + "Mod" + os.sep + self.repo.name elif self.repo.update_status == AddonManagerRepo.UpdateStatus.UPDATE_AVAILABLE: message = """ @@ -645,7 +653,7 @@ class ShowWorker(QtCore.QThread): message += translate("AddonsInstaller", "An update is available for this addon.") message += "

" + desc + '

Addon repository: ' + self.repo.url + "" - message += "
" + translate("AddonInstaller", "Installed location") + ": " + message += "
" + translate("AddonsInstaller", "Installed location") + ": " message += FreeCAD.getUserAppDataDir() + os.sep + "Mod" + os.sep + self.repo.name else: message = desc + '

Addon repository: " + self.macro.name + "" + self.macro.desc + "

Macro location:
" + self.macro.url + "") + if QtCore.QThread.currentThread().isInterruptionRequested(): + return self.description_updated.emit(message) self.done.emit() self.stop = True @@ -796,7 +812,7 @@ class InstallWorkbenchWorker(QtCore.QThread): QtCore.QThread.__init__(self) self.repo = repo - if have_git: + if have_git and not NOGIT: self.git_progress = GitProgressMonitor() # TODO: What is wrong with these? #self.git_progress.progress_made.connect(self.progress_made.emit) @@ -808,7 +824,7 @@ class InstallWorkbenchWorker(QtCore.QThread): if not self.repo: return - if not have_git: + if not have_git or NOGIT: FreeCAD.Console.PrintWarning(translate("AddonsInstaller", "GitPython not found. Using ZIP file download instead.") + "\n") if not have_zip: @@ -823,7 +839,7 @@ class InstallWorkbenchWorker(QtCore.QThread): os.makedirs(moddir) target_dir = moddir + os.sep + self.repo.name - if have_git: + if have_git and not NOGIT: self.run_git(target_dir) else: self.run_zip(target_dir) @@ -832,6 +848,12 @@ class InstallWorkbenchWorker(QtCore.QThread): def run_git(self, clonedir:str) -> None: + if NOGIT or not have_git: + FreeCAD.Console.PrintWarning(translate("AddonsInstaller", + "No Git Python installed, skipping git operations") + "\n") + return + + if os.path.exists(clonedir): self.run_git_update(clonedir) else: @@ -1049,7 +1071,7 @@ class CheckSingleWorker(QtCore.QThread): def run(self): - if not have_git: + if not have_git or NOGIT: return FreeCAD.Console.PrintLog("Checking for available updates of the " + self.name + " addon\n") addondir = os.path.join(FreeCAD.getUserAppDataDir(), "Mod", self.name) @@ -1098,8 +1120,6 @@ class UpdateMetadataCacheWorker(QtCore.QThread): self.counter = UpdateMetadataCacheWorker.AtomicCounter() def run(self): - if not have_git: - return current_thread = QtCore.QThread.currentThread() self.num_downloads_required = len(self.repos) self.progress_made.emit(0, self.num_downloads_required) @@ -1117,10 +1137,11 @@ class UpdateMetadataCacheWorker(QtCore.QThread): self.downloaders = [] for repo in self.repos: - downloader = MetadataDownloadWorker(None, repo, self.index) - downloader.start_fetch(download_queue) - downloader.updated.connect(self.on_updated) - self.downloaders.append(downloader) + if repo.metadata_url: + downloader = MetadataDownloadWorker(None, repo, self.index) + downloader.start_fetch(download_queue) + downloader.updated.connect(self.on_updated) + self.downloaders.append(downloader) # Run a local event loop until we've processed all of the downloads: # this is local @@ -1128,7 +1149,7 @@ class UpdateMetadataCacheWorker(QtCore.QThread): ui_updater = QtCore.QTimer() ui_updater.timeout.connect(self.send_ui_update) ui_updater.start(100) # Send an update back to the main thread every 100ms - self.num_downloads_required = len(self.repos) + self.num_downloads_required = len(self.downloaders) self.num_downloads_completed = UpdateMetadataCacheWorker.AtomicCounter() while True: if current_thread.isInterruptionRequested(): @@ -1139,7 +1160,6 @@ class UpdateMetadataCacheWorker(QtCore.QThread): break if current_thread.isInterruptionRequested(): - FreeCAD.Console.PrintMessage("Bailing out of downloads") return # Update and serialize the updated index, overwriting whatever was @@ -1168,20 +1188,21 @@ class UpdateMetadataCacheWorker(QtCore.QThread): self.progress_made.emit(self.num_downloads_completed.get(), self.num_downloads_required) -class GitProgressMonitor(git.RemoteProgress): - """ An object that receives git progress updates and transforms them into Qt signals """ +if have_git and not NOGIT: + class GitProgressMonitor(git.RemoteProgress): + """ An object that receives git progress updates and transforms them into Qt signals """ - progress_made = QtCore.Signal(int, int) - info_message = QtCore.Signal(str) + progress_made = QtCore.Signal(int, int) + info_message = QtCore.Signal(str) - def __init__(self): - super().__init__() + def __init__(self): + super().__init__() - def update(self, op_code: int, cur_count: Union[str, float], max_count: Union[str, float, None]=None, message: str='') -> None: - if max_count: - self.progress_made.emit(int(cur_count), int(max_count)) - if message: - self.info_message.emit(message) + def update(self, op_code: int, cur_count: Union[str, float], max_count: Union[str, float, None]=None, message: str='') -> None: + if max_count: + self.progress_made.emit(int(cur_count), int(max_count)) + if message: + self.info_message.emit(message) class UpdateAllWorker(QtCore.QThread): @@ -1291,4 +1312,4 @@ class UpdateSingleWorker(QtCore.QThread): if not worker.isRunning(): break -# @} \ No newline at end of file +# @}