From 19664a0e47ea157e70fb4ec4a04953a7dd1aad18 Mon Sep 17 00:00:00 2001 From: RumovZ Date: Thu, 28 Apr 2022 17:05:18 +0200 Subject: [PATCH] Remove OpWithBackendProgress and ClosedCollectionOp Backend progress logic is now in ProgressManager. QueryOp can be used for running on closed collection. Also fix aborting of colpkg exports, which slipped through in #1817. --- qt/aqt/import_export/exporting.py | 29 ++--- qt/aqt/import_export/importing.py | 51 +++++---- qt/aqt/main.py | 4 +- qt/aqt/operations/__init__.py | 170 ++++++------------------------ qt/aqt/progress.py | 31 ++++++ qt/aqt/taskman.py | 22 ++++ 6 files changed, 135 insertions(+), 172 deletions(-) diff --git a/qt/aqt/import_export/exporting.py b/qt/aqt/import_export/exporting.py index 9994295a8..d7858417c 100644 --- a/qt/aqt/import_export/exporting.py +++ b/qt/aqt/import_export/exporting.py @@ -7,18 +7,17 @@ import os import re import time from abc import ABC, abstractmethod -from concurrent.futures import Future from dataclasses import dataclass from typing import Sequence, Type import aqt.forms import aqt.main -from anki.collection import DeckIdLimit, ExportLimit, NoteIdsLimit +from anki.collection import DeckIdLimit, ExportLimit, NoteIdsLimit, Progress from anki.decks import DeckId, DeckNameId from anki.notes import NoteId from aqt import gui_hooks from aqt.errors import show_exception -from aqt.operations import QueryOpWithBackendProgress +from aqt.operations import QueryOp from aqt.qt import * from aqt.utils import ( checkInvalidFilename, @@ -180,7 +179,7 @@ class ColpkgExporter(Exporter): @staticmethod def export(mw: aqt.main.AnkiQt, options: Options) -> None: - def on_success(_future: Future) -> None: + def on_success(_: None) -> None: mw.reopen() tooltip(tr.exporting_collection_exported(), parent=mw) @@ -189,14 +188,15 @@ class ColpkgExporter(Exporter): show_exception(parent=mw, exception=exception) gui_hooks.collection_will_temporarily_close(mw.col) - QueryOpWithBackendProgress( + QueryOp( parent=mw, op=lambda col: col.export_collection_package( options.out_path, include_media=options.include_media, legacy=False ), success=on_success, - key="exporting", - ).failure(on_failure).run_in_background() + ).with_backend_progress(export_progress_label).failure( + on_failure + ).run_in_background() class ApkgExporter(Exporter): @@ -211,7 +211,7 @@ class ApkgExporter(Exporter): @staticmethod def export(mw: aqt.main.AnkiQt, options: Options) -> None: - QueryOpWithBackendProgress( + QueryOp( parent=mw, op=lambda col: col.export_anki_package( out_path=options.out_path, @@ -219,8 +219,13 @@ class ApkgExporter(Exporter): with_scheduling=options.include_scheduling, with_media=options.include_media, ), - success=lambda fut: tooltip( - tr.exporting_note_exported(count=fut), parent=mw + success=lambda count: tooltip( + tr.exporting_note_exported(count=count), parent=mw ), - key="exporting", - ).run_in_background() + ).with_backend_progress(export_progress_label).run_in_background() + + +def export_progress_label(progress: Progress) -> str | None: + if not progress.HasField("exporting"): + return None + return tr.exporting_exported_media_file(count=progress.exporting) diff --git a/qt/aqt/import_export/importing.py b/qt/aqt/import_export/importing.py index 71818d687..ec42829a2 100644 --- a/qt/aqt/import_export/importing.py +++ b/qt/aqt/import_export/importing.py @@ -3,14 +3,12 @@ from __future__ import annotations -from concurrent.futures import Future +from typing import Sequence import aqt.main +from anki.collection import Collection, ImportLogWithChanges, Progress from anki.errors import Interrupted -from aqt.operations import ( - ClosedCollectionOpWithBackendProgress, - CollectionOpWithBackendProgress, -) +from aqt.operations import CollectionOp, QueryOp from aqt.qt import * from aqt.utils import askUser, getFile, showInfo, showText, showWarning, tooltip, tr @@ -62,7 +60,7 @@ def maybe_import_collection_package(mw: aqt.main.AnkiQt, path: str) -> None: def import_collection_package(mw: aqt.main.AnkiQt, file: str) -> None: - def on_success(_future: Future) -> None: + def on_success() -> None: mw.loadCollection() tooltip(tr.importing_importing_complete()) @@ -71,38 +69,43 @@ def import_collection_package(mw: aqt.main.AnkiQt, file: str) -> None: if not isinstance(err, Interrupted): showWarning(str(err)) - import_collection_package_op(mw, file).success(on_success).failure( - on_failure - ).run_in_background() + QueryOp( + parent=mw, + op=lambda _: mw.create_backup_now(), + success=lambda _: mw.unloadCollection( + lambda: import_collection_package_op(mw, file, on_success) + .failure(on_failure) + .run_in_background() + ), + ).with_progress().run_in_background() def import_collection_package_op( - mw: aqt.main.AnkiQt, path: str -) -> ClosedCollectionOpWithBackendProgress: - def op() -> None: + mw: aqt.main.AnkiQt, path: str, success: Callable[[], None] +) -> QueryOp[None]: + def op(_: Collection) -> None: col_path = mw.pm.collectionPath() media_folder = os.path.join(mw.pm.profileFolder(), "collection.media") mw.backend.import_collection_package( col_path=col_path, backup_path=path, media_folder=media_folder ) - return ClosedCollectionOpWithBackendProgress( - parent=mw, - op=op, - key="importing", + return QueryOp(parent=mw, op=op, success=lambda _: success()).with_backend_progress( + import_progress_label ) def import_anki_package(mw: aqt.main.AnkiQt, path: str) -> None: - CollectionOpWithBackendProgress( + CollectionOp( parent=mw, op=lambda col: col.import_anki_package(path), - key="importing", - ).success(show_import_log).run_in_background() + ).with_backend_progress(import_progress_label).success( + show_import_log + ).run_in_background() -def show_import_log(future: Future) -> None: - log = future.log # type: ignore +def show_import_log(log_with_changes: ImportLogWithChanges) -> None: + log = log_with_changes.log # type: ignore total = len(log.conflicting) + len(log.updated) + len(log.new) + len(log.duplicate) text = f"""{tr.importing_notes_found_in_file(val=total)} @@ -120,5 +123,9 @@ def show_import_log(future: Future) -> None: showText(text, plain_text_edit=True) -def log_rows(rows: list, action: str) -> str: +def log_rows(rows: Sequence, action: str) -> str: return "\n".join(f"[{action}] {', '.join(note.fields)}" for note in rows) + + +def import_progress_label(progress: Progress) -> str | None: + return progress.importing if progress.HasField("importing") else None diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 22633b832..46ac981d2 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -407,8 +407,8 @@ class AnkiQt(QMainWindow): self.restoring_backup = True showInfo(tr.qt_misc_automatic_syncing_and_backups_have_been()) - import_collection_package_op(self, path).success( - lambda _: self.onOpenProfile() + import_collection_package_op( + self, path, success=self.onOpenProfile ).run_in_background() def _on_downgrade(self) -> None: diff --git a/qt/aqt/operations/__init__.py b/qt/aqt/operations/__init__.py index 6d4428a17..221bc35b2 100644 --- a/qt/aqt/operations/__init__.py +++ b/qt/aqt/operations/__init__.py @@ -3,9 +3,8 @@ from __future__ import annotations -from abc import ABC from concurrent.futures._base import Future -from typing import Any, Callable, Generic, Literal, Protocol, TypeVar, Union +from typing import Any, Callable, Generic, Protocol, TypeVar, Union import aqt import aqt.gui_hooks @@ -20,8 +19,7 @@ from anki.collection import ( Progress, ) from aqt.errors import show_exception -from aqt.qt import QTimer, QWidget, qconnect -from aqt.utils import tr +from aqt.qt import QWidget class HasChangesProperty(Protocol): @@ -70,6 +68,7 @@ class CollectionOp(Generic[ResultWithChanges]): _success: Callable[[ResultWithChanges], Any] | None = None _failure: Callable[[Exception], Any] | None = None + _label_from_progress: Callable[[Progress], str | None] | None = None def __init__(self, parent: QWidget, op: Callable[[Collection], ResultWithChanges]): self._parent = parent @@ -87,6 +86,12 @@ class CollectionOp(Generic[ResultWithChanges]): self._failure = failure return self + def with_backend_progress( + self, label_from_progress: Callable[[Progress], str | None] | None + ) -> CollectionOp[ResultWithChanges]: + self._label_from_progress = label_from_progress + return self + def run_in_background(self, *, initiator: object | None = None) -> None: from aqt import mw @@ -128,7 +133,12 @@ class CollectionOp(Generic[ResultWithChanges]): op: Callable[[], ResultWithChanges], on_done: Callable[[Future], None], ) -> None: - mw.taskman.with_progress(op, on_done) + if self._label_from_progress: + mw.taskman.with_backend_progress( + op, self._label_from_progress, on_done=on_done, parent=self._parent + ) + else: + mw.taskman.with_progress(op, on_done, parent=self._parent) def _finish_op( self, mw: aqt.main.AnkiQt, result: ResultWithChanges, initiator: object | None @@ -185,6 +195,7 @@ class QueryOp(Generic[T]): _failure: Callable[[Exception], Any] | None = None _progress: bool | str = False + _label_from_progress: Callable[[Progress], str | None] | None = None def __init__( self, @@ -209,6 +220,12 @@ class QueryOp(Generic[T]): self._progress = label or True return self + def with_backend_progress( + self, label_from_progress: Callable[[Progress], str | None] | None + ) -> QueryOp[T]: + self._label_from_progress = label_from_progress + return self + def run_in_background(self) -> None: from aqt import mw @@ -218,26 +235,11 @@ class QueryOp(Generic[T]): def wrapped_op() -> T: assert mw - if self._progress: - label: str | None - if isinstance(self._progress, str): - label = self._progress - else: - label = None - - def start_progress() -> None: - assert mw - mw.progress.start(label=label) - - mw.taskman.run_on_main(start_progress) return self._op(mw.col) def wrapped_done(future: Future) -> None: assert mw - if self._progress: - mw.progress.finish() - mw._decrease_background_ops() # did something go wrong? if exception := future.exception(): @@ -261,120 +263,16 @@ class QueryOp(Generic[T]): op: Callable[[], T], on_done: Callable[[Future], None], ) -> None: - mw.taskman.run_in_background(op, on_done) - - -class OpWithBackendProgress(ABC): - """Periodically queries the backend for progress updates, and enables abortion. - - Requires a key for a value on the `Progress` proto message.""" - - def __init__( - self, - *args: Any, - key: Literal["importing", "exporting"], - **kwargs: Any, - ): - self._key = key - self._timer = QTimer() - self._timer.setSingleShot(False) - self._timer.setInterval(100) - super().__init__(*args, **kwargs) - - def _run( - self, - mw: aqt.main.AnkiQt, - op: Callable, - on_done: Callable[[Future], None], - ) -> None: - if not (dialog := mw.progress.start(immediate=True, parent=mw)): - print("Progress dialog already running; aborting will not work") - - def on_progress() -> None: - assert mw - - progress = mw.backend.latest_progress() - if not (label := label_from_progress(progress, self._key)): - return - if dialog and dialog.wantCancel: - mw.backend.set_wants_abort() - mw.taskman.run_on_main(lambda: mw.progress.update(label=label)) - - def wrapped_on_done(future: Future) -> None: - self._timer.deleteLater() - assert mw - mw.progress.finish() - on_done(future) - - qconnect(self._timer.timeout, on_progress) - self._timer.start() - mw.taskman.run_in_background(task=op, on_done=wrapped_on_done) - - -def label_from_progress( - progress: Progress, - key: Literal["importing", "exporting"], -) -> str | None: - if not progress.HasField(key): - return None - if key == "importing": - return progress.importing - if key == "exporting": - return tr.exporting_exported_media_file(count=progress.exporting) - - -class CollectionOpWithBackendProgress(OpWithBackendProgress, CollectionOp): - pass - - -class QueryOpWithBackendProgress(OpWithBackendProgress, QueryOp): - def with_progress(self, *_args: Any) -> Any: - raise NotImplementedError - - -class ClosedCollectionOp(CollectionOp): - """For CollectionOps that need to be run on a closed collection. - - If a collection is open, backs it up and unloads it, before running the op. - Reloads it, if that has not been done by a callback, yet. - """ - - def __init__( - self, - parent: QWidget, - op: Callable[[], ResultWithChanges], - *args: Any, - **kwargs: Any, - ): - super().__init__(parent, lambda _: op(), *args, **kwargs) - - def _run( - self, - mw: aqt.main.AnkiQt, - op: Callable[[], ResultWithChanges], - on_done: Callable[[Future], None], - ) -> None: - if mw.col: - QueryOp( - parent=mw, - op=lambda _: mw.create_backup_now(), - success=lambda _: mw.unloadCollection( - lambda: super(ClosedCollectionOp, self)._run(mw, op, on_done) - ), - ).with_progress().run_in_background() + label = self._progress if isinstance(self._progress, str) else None + if self._label_from_progress: + mw.taskman.with_backend_progress( + op, + self._label_from_progress, + on_done=on_done, + start_label=label, + parent=self._parent, + ) + elif self._progress: + mw.taskman.with_progress(op, on_done, label=label, parent=self._parent) else: - super()._run(mw, op, on_done) - - def _finish_op( - self, - _mw: aqt.main.AnkiQt, - _result: ResultWithChanges, - _initiator: object | None, - ) -> None: - pass - - -class ClosedCollectionOpWithBackendProgress( - ClosedCollectionOp, CollectionOpWithBackendProgress -): - """See ClosedCollectionOp and CollectionOpWithBackendProgress.""" + mw.taskman.run_in_background(op, on_done) diff --git a/qt/aqt/progress.py b/qt/aqt/progress.py index 3aea4f652..b0a93830d 100644 --- a/qt/aqt/progress.py +++ b/qt/aqt/progress.py @@ -6,6 +6,7 @@ import time import aqt.forms from anki._legacy import print_deprecation_warning +from anki.collection import Progress from aqt.qt import * from aqt.utils import disable_help_button, tr @@ -23,6 +24,7 @@ class ProgressManager: self._busy_cursor_timer: QTimer | None = None self._win: ProgressDialog | None = None self._levels = 0 + self._backend_timer: QTimer | None = None # Safer timers ########################################################################## @@ -166,6 +168,32 @@ class ProgressManager: qconnect(self._show_timer.timeout, self._on_show_timer) return self._win + def start_with_backend_updates( + self, + label_from_progress: Callable[[Progress], str | None], + start_label: str | None = None, + parent: QWidget | None = None, + ) -> None: + self._backend_timer = QTimer() + self._backend_timer.setSingleShot(False) + self._backend_timer.setInterval(100) + + if not (dialog := self.start(immediate=True, label=start_label, parent=parent)): + print("Progress dialog already running; aborting will not work") + + def on_progress() -> None: + assert self.mw + + progress = self.mw.backend.latest_progress() + if not (label := label_from_progress(progress)): + return + if dialog and dialog.wantCancel: + self.mw.backend.set_wants_abort() + self.update(label=label) + + qconnect(self._backend_timer.timeout, on_progress) + self._backend_timer.start() + def update( self, label: str | None = None, @@ -204,6 +232,9 @@ class ProgressManager: if self._show_timer: self._show_timer.stop() self._show_timer = None + if self._backend_timer: + self._backend_timer.deleteLater() + self._backend_timer = None def clear(self) -> None: "Restore the interface after an error." diff --git a/qt/aqt/taskman.py b/qt/aqt/taskman.py index b763f96d4..acf34adbc 100644 --- a/qt/aqt/taskman.py +++ b/qt/aqt/taskman.py @@ -15,6 +15,7 @@ from threading import Lock from typing import Any, Callable import aqt +from anki.collection import Progress from aqt.qt import * Closure = Callable[[], None] @@ -89,6 +90,27 @@ class TaskManager(QObject): self.run_in_background(task, wrapped_done) + def with_backend_progress( + self, + task: Callable, + label_from_progress: Callable[[Progress], str | None], + on_done: Callable[[Future], None] | None = None, + parent: QWidget | None = None, + start_label: str | None = None, + ) -> None: + self.mw.progress.start_with_backend_updates( + label_from_progress, + parent=parent, + start_label=start_label, + ) + + def wrapped_done(fut: Future) -> None: + self.mw.progress.finish() + if on_done: + on_done(fut) + + self.run_in_background(task, wrapped_done) + def _on_closures_pending(self) -> None: """Run any pending closures. This runs in the main thread.""" with self._closures_lock: