Merge pull request #313 from oakkitten/anki50

Fix tests and some of the code to work with Anki 2.1.50
This commit is contained in:
Alex Yatskov 2022-04-26 20:57:20 -07:00 committed by GitHub
commit fe8b221a93
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 242 additions and 80 deletions

View File

@ -12,8 +12,15 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install -y pyqt5-dev-tools xvfb sudo apt-get install -y pyqt5-dev-tools xvfb
- name: Setup Python - name: Setup Python 3.8
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with:
python-version: 3.8
- name: Setup Python 3.9
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install tox - name: Install tox
run: pip install tox run: pip install tox

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
AnkiConnect.zip AnkiConnect.zip
meta.json meta.json
.idea/ .idea/
.tox/

View File

@ -13,6 +13,13 @@
# You should have received a copy of the GNU General Public License # You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import aqt
anki_version = tuple(int(segment) for segment in aqt.appVersion.split("."))
if anki_version < (2, 1, 45):
raise Exception("Minimum Anki version supported: 2.1.45")
import base64 import base64
import glob import glob
import hashlib import hashlib
@ -20,34 +27,23 @@ import inspect
import json import json
import os import os
import os.path import os.path
import random import platform
import re import re
import string
import time import time
import unicodedata import unicodedata
from PyQt5 import QtCore
from PyQt5.QtCore import QTimer
from PyQt5.QtWidgets import QMessageBox, QCheckBox
import anki import anki
import anki.exporting import anki.exporting
import anki.storage import anki.storage
import aqt
from anki.cards import Card from anki.cards import Card
from anki.consts import MODEL_CLOZE from anki.consts import MODEL_CLOZE
from anki.exporting import AnkiPackageExporter from anki.exporting import AnkiPackageExporter
from anki.importing import AnkiPackageImporter from anki.importing import AnkiPackageImporter
from anki.notes import Note from anki.notes import Note
from anki.errors import NotFoundError
from aqt.qt import Qt, QTimer, QMessageBox, QCheckBox
from .edit import Edit from .edit import Edit
try:
from anki.rsbackend import NotFoundError
except:
NotFoundError = Exception
from . import web, util from . import web, util
@ -56,8 +52,6 @@ from . import web, util
# #
class AnkiConnect: class AnkiConnect:
_anki21_version = int(aqt.appVersion.split('.')[-1])
def __init__(self): def __init__(self):
self.log = None self.log = None
self.timer = None self.timer = None
@ -84,11 +78,7 @@ class AnkiConnect:
) )
def save_model(self, models, ankiModel): def save_model(self, models, ankiModel):
if self._anki21_version < 45: models.update_dict(ankiModel)
models.save(ankiModel, True)
models.flush()
else:
models.update_dict(ankiModel)
def logEvent(self, name, data): def logEvent(self, name, data):
if self.log is not None: if self.log is not None:
@ -391,7 +381,7 @@ class AnkiConnect:
msg.setStandardButtons(QMessageBox.Yes|QMessageBox.No) msg.setStandardButtons(QMessageBox.Yes|QMessageBox.No)
msg.setDefaultButton(QMessageBox.No) msg.setDefaultButton(QMessageBox.No)
msg.setCheckBox(QCheckBox(text='Ignore further requests from "{}"'.format(origin), parent=msg)) msg.setCheckBox(QCheckBox(text='Ignore further requests from "{}"'.format(origin), parent=msg))
msg.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint) msg.setWindowFlags(Qt.WindowStaysOnTopHint)
pressedButton = msg.exec_() pressedButton = msg.exec_()
if pressedButton == QMessageBox.Yes: if pressedButton == QMessageBox.Yes:
@ -547,9 +537,8 @@ class AnkiConnect:
# however, since 62c23c6816adf912776b9378c008a52bb50b2e8d (2.1.45) # however, since 62c23c6816adf912776b9378c008a52bb50b2e8d (2.1.45)
# passing cardsToo to `rem` (long deprecated) won't raise an error! # passing cardsToo to `rem` (long deprecated) won't raise an error!
# this is dangerous, so let's raise our own exception # this is dangerous, so let's raise our own exception
if self._anki21_version >= 28: raise Exception("Since Anki 2.1.28 it's not possible "
raise Exception("Since Anki 2.1.28 it's not possible " "to delete decks without deleting cards as well")
"to delete decks without deleting cards as well")
try: try:
self.startEditing() self.startEditing()
decks = filter(lambda d: d in self.deckNames(), decks) decks = filter(lambda d: d in self.deckNames(), decks)
@ -1425,9 +1414,7 @@ class AnkiConnect:
if savedMid: if savedMid:
deck['mid'] = savedMid deck['mid'] = savedMid
addCards.editor.note = ankiNote addCards.editor.set_note(ankiNote)
addCards.editor.loadNote()
addCards.editor.updateTags()
addCards.activateWindow() addCards.activateWindow()
@ -1633,6 +1620,9 @@ class AnkiConnect:
# when run inside Anki, `__name__` would be either numeric, # when run inside Anki, `__name__` would be either numeric,
# or, if installed via `link.sh`, `AnkiConnectDev` # or, if installed via `link.sh`, `AnkiConnectDev`
if __name__ != "plugin": if __name__ != "plugin":
if platform.system() == "Windows" and anki_version == (2, 1, 50):
util.patch_anki_2_1_50_having_null_stdout_on_windows()
Edit.register_with_anki() Edit.register_with_anki()
ac = AnkiConnect() ac = AnkiConnect()

View File

@ -1,5 +1,6 @@
import aqt import aqt
import aqt.editor import aqt.editor
import aqt.browser.previewer
from aqt import gui_hooks from aqt import gui_hooks
from aqt.qt import QDialog, Qt, QKeySequence, QShortcut from aqt.qt import QDialog, Qt, QKeySequence, QShortcut
from aqt.utils import disable_help_button, restoreGeom, saveGeom, tooltip from aqt.utils import disable_help_button, restoreGeom, saveGeom, tooltip
@ -7,6 +8,8 @@ from anki.errors import NotFoundError
from anki.consts import QUEUE_TYPE_SUSPENDED from anki.consts import QUEUE_TYPE_SUSPENDED
from anki.utils import ids2str from anki.utils import ids2str
from . import anki_version
# Edit dialog. Like Edit Current, but: # Edit dialog. Like Edit Current, but:
# * has a Preview button to preview the cards for the note # * has a Preview button to preview the cards for the note
@ -184,7 +187,7 @@ class Edit(aqt.editcurrent.EditCurrent):
# upon a request to open the dialog via `aqt.dialogs.open()`, # upon a request to open the dialog via `aqt.dialogs.open()`,
# the manager will call either the constructor or the `reopen` method # the manager will call either the constructor or the `reopen` method
def __init__(self, note): def __init__(self, note):
QDialog.__init__(self, None, Qt.Window) QDialog.__init__(self, None, Qt.WindowType.Window)
aqt.mw.garbage_collect_on_dialog_finish(self) aqt.mw.garbage_collect_on_dialog_finish(self)
self.form = aqt.forms.editcurrent.Ui_Dialog() self.form = aqt.forms.editcurrent.Ui_Dialog()
self.form.setupUi(self) self.form.setupUi(self)
@ -225,8 +228,6 @@ class Edit(aqt.editcurrent.EditCurrent):
if changes.note_text and handler is not self.editor: if changes.note_text and handler is not self.editor:
self.reload_notes_after_user_action_elsewhere() self.reload_notes_after_user_action_elsewhere()
# adjusting buttons right after initializing doesn't have any effect;
# this seems to do the trick
def editor_did_load_note(self, _editor): def editor_did_load_note(self, _editor):
self.enable_disable_next_and_previous_buttons() self.enable_disable_next_and_previous_buttons()
@ -304,29 +305,66 @@ class Edit(aqt.editcurrent.EditCurrent):
gui_hooks.editor_did_init.append(self.add_preview_button) gui_hooks.editor_did_init.append(self.add_preview_button)
gui_hooks.editor_did_init_buttons.append(self.add_right_hand_side_buttons) gui_hooks.editor_did_init_buttons.append(self.add_right_hand_side_buttons)
self.editor = aqt.editor.Editor(aqt.mw, self.form.fieldsArea, self) # on Anki 2.1.50, browser mode makes the Preview button visible
extra_kwargs = {} if anki_version < (2, 1, 50) else {
"editor_mode": aqt.editor.EditorMode.BROWSER
}
self.editor = aqt.editor.Editor(aqt.mw, self.form.fieldsArea, self,
**extra_kwargs)
gui_hooks.editor_did_init_buttons.remove(self.add_right_hand_side_buttons) gui_hooks.editor_did_init_buttons.remove(self.add_right_hand_side_buttons)
gui_hooks.editor_did_init.remove(self.add_preview_button) gui_hooks.editor_did_init.remove(self.add_preview_button)
# taken from `setupEditor` of browser.py # * on Anki < 2.1.50, make the button via js (`setupEditor` of browser.py);
# PreviewButton calls pycmd `preview`, which is hardcoded. # also, make a copy of _links so that opening Anki's browser does not
# copying _links is needed so that opening Anki's browser does not # screw them up as they are apparently shared between instances?!
# screw them up as they are apparently shared between instances?! # the last part seems to have been fixed in Anki 2.1.50
# * on Anki 2.1.50, the button is created by setting editor mode,
# see above; so we only need to add the link.
def add_preview_button(self, editor): def add_preview_button(self, editor):
QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.show_preview) QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.show_preview)
editor._links = editor._links.copy() if anki_version < (2, 1, 50):
editor._links["preview"] = self.show_preview editor._links = editor._links.copy()
editor.web.eval(""" editor.web.eval("""
$editorToolbar.then(({notetypeButtons}) => $editorToolbar.then(({notetypeButtons}) =>
notetypeButtons.appendButton( notetypeButtons.appendButton(
{component: editorToolbar.PreviewButton, id: 'preview'} {component: editorToolbar.PreviewButton, id: 'preview'}
) )
); );
""") """)
editor._links["preview"] = lambda _editor: self.show_preview() and None
# * on Anki < 2.1.50, button style is okay-ish from get-go,
# except when disabled; adding class `btn` fixes that;
# * on Anki 2.1.50, buttons have weird font size and are square';
# the style below makes them in line with left-hand side buttons
def add_right_hand_side_buttons(self, buttons, editor): def add_right_hand_side_buttons(self, buttons, editor):
if anki_version < (2, 1, 50):
extra_button_class = "btn"
else:
extra_button_class = "anki-connect-button"
editor.web.eval("""
(function(){
const style = document.createElement("style");
style.innerHTML = `
.anki-connect-button {
white-space: nowrap;
width: auto;
padding: 0 2px;
font-size: var(--base-font-size);
}
.anki-connect-button:disabled {
pointer-events: none;
opacity: .4;
}
`;
document.head.appendChild(style);
})();
""")
def add(cmd, function, label, tip, keys): def add(cmd, function, label, tip, keys):
button_html = editor.addButton( button_html = editor.addButton(
icon=None, icon=None,
@ -338,30 +376,34 @@ class Edit(aqt.editcurrent.EditCurrent):
keys=keys, keys=keys,
) )
# adding class `btn` properly styles buttons when disabled button_html = button_html.replace('class="',
button_html = button_html.replace('class="', 'class="btn ') f'class="{extra_button_class} ')
buttons.append(button_html) buttons.append(button_html)
add("browse", self.show_browser, "Browse", "Browse", "Ctrl+F") add("browse", self.show_browser, "Browse", "Browse", "Ctrl+F")
add("previous", self.show_previous, "&lt;", "Previous", "Alt+Left") add("previous", self.show_previous, "&lt;", "Previous", "Alt+Left")
add("next", self.show_next, "&gt;", "Next", "Alt+Right") add("next", self.show_next, "&gt;", "Next", "Alt+Right")
def run_javascript_after_toolbar_ready(self, js):
js = f"setTimeout(function() {{ {js} }}, 1)"
if anki_version < (2, 1, 50):
js = f'$editorToolbar.then(({{ toolbar }}) => {js})'
else:
js = f'require("anki/ui").loaded.then(() => {js})'
self.editor.web.eval(js)
def enable_disable_next_and_previous_buttons(self): def enable_disable_next_and_previous_buttons(self):
def to_js(boolean): def to_js(boolean):
return "true" if boolean else "false" return "true" if boolean else "false"
disable_previous = to_js(not(history.has_note_to_left_of(self.note))) disable_previous = not(history.has_note_to_left_of(self.note))
disable_next = to_js(not(history.has_note_to_right_of(self.note))) disable_next = not(history.has_note_to_right_of(self.note))
self.editor.web.eval(f""" self.run_javascript_after_toolbar_ready(f"""
$editorToolbar.then(({{ toolbar }}) => {{ document.getElementById("{DOMAIN_PREFIX}previous")
setTimeout(function() {{ .disabled = {to_js(disable_previous)};
document.getElementById("{DOMAIN_PREFIX}previous") document.getElementById("{DOMAIN_PREFIX}next")
.disabled = {disable_previous}; .disabled = {to_js(disable_next)};
document.getElementById("{DOMAIN_PREFIX}next")
.disabled = {disable_next};
}}, 1);
}});
""") """)
########################################################################## ##########################################################################

View File

@ -14,6 +14,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>. # along with this program. If not, see <http://www.gnu.org/licenses/>.
import os import os
import sys
import anki import anki
import anki.sync import anki.sync
@ -83,3 +84,10 @@ def setting(key):
return aqt.mw.addonManager.getConfig(__name__).get(key, DEFAULT_CONFIG[key]) return aqt.mw.addonManager.getConfig(__name__).get(key, DEFAULT_CONFIG[key])
except: except:
raise Exception('setting {} not found'.format(key)) raise Exception('setting {} not found'.format(key))
# see https://github.com/FooSoft/anki-connect/issues/308
# fixed in https://github.com/ankitects/anki/commit/0b2a226d
def patch_anki_2_1_50_having_null_stdout_on_windows():
if sys.stdout is None:
sys.stdout = open(os.devnull, "w", encoding="utf8")

View File

@ -5,15 +5,20 @@ from dataclasses import dataclass
import aqt.operations.note import aqt.operations.note
import pytest import pytest
from PyQt5 import QtTest import anki.collection
from _pytest.monkeypatch import MonkeyPatch # noqa from _pytest.monkeypatch import MonkeyPatch # noqa
from pytest_anki._launch import anki_running, temporary_user # noqa from pytest_anki._launch import anki_running, temporary_user # noqa
from waitress import wasyncore from waitress import wasyncore
from plugin import AnkiConnect from plugin import AnkiConnect, anki_version
from plugin.edit import Edit from plugin.edit import Edit
from plugin.util import DEFAULT_CONFIG from plugin.util import DEFAULT_CONFIG
try:
from PyQt6 import QtTest
except ImportError:
from PyQt5 import QtTest
ac = AnkiConnect() ac = AnkiConnect()
@ -77,22 +82,33 @@ def waitress_patched_to_prevent_it_from_dying():
yield yield
@contextmanager
def anki_patched_to_prevent_backups():
with MonkeyPatch().context() as monkey:
if anki_version < (2, 1, 50):
monkey.setitem(aqt.profiles.profileConf, "numBackups", 0)
else:
monkey.setattr(anki.collection.Collection, "create_backup",
lambda *args, **kwargs: True)
yield
@contextmanager @contextmanager
def empty_anki_session_started(): def empty_anki_session_started():
with waitress_patched_to_prevent_it_from_dying(): with waitress_patched_to_prevent_it_from_dying():
with anki_running( with anki_patched_to_prevent_backups():
qtbot=None, # noqa with anki_running(
enable_web_debugging=False, qtbot=None, # noqa
profile_name="test_user", enable_web_debugging=False,
) as session: profile_name="test_user",
yield session ) as session:
yield session
@contextmanager @contextmanager
def profile_created_and_loaded(session): def profile_created_and_loaded(session):
with temporary_user(session.base, "test_user", "en_US"): with temporary_user(session.base, "test_user", "en_US"):
with session.profile_loaded(): with session.profile_loaded():
aqt.mw.pm.profile["numBackups"] = 0 # don't try to do backups
yield session yield session

View File

@ -30,10 +30,6 @@ def test_deleteDeck(setup):
assert {*before} - {*after} == {"test_deck"} assert {*before} - {*after} == {"test_deck"}
@pytest.mark.skipif(
condition=ac._anki21_version < 28,
reason=f"Not applicable to Anki < 2.1.28"
)
def test_deleteDeck_must_be_called_with_cardsToo_set_to_True_on_later_api(setup): def test_deleteDeck_must_be_called_with_cardsToo_set_to_True_on_later_api(setup):
with pytest.raises(Exception): with pytest.raises(Exception):
ac.deleteDecks(decks=["test_deck"]) ac.deleteDecks(decks=["test_deck"])

View File

@ -1,8 +1,63 @@
from dataclasses import dataclass
from unittest.mock import MagicMock
import aqt.operations.note import aqt.operations.note
import pytest import pytest
from conftest import get_dialog_instance from conftest import get_dialog_instance, wait_until
from plugin.edit import Edit, DecentPreviewer, history from plugin.edit import Edit, DecentPreviewer, history, DOMAIN_PREFIX
NOTHING = object()
class Value:
def __init__(self):
self.value = NOTHING
def set(self, value):
self.value = value
def has_been_set(self):
return self.value is not NOTHING
@dataclass
class JavascriptDialogButtonManipulator:
dialog: ...
def eval_js(self, js):
evaluation_result = Value()
self.dialog.editor.web.evalWithCallback(js, evaluation_result.set)
wait_until(evaluation_result.has_been_set)
return evaluation_result.value
def wait_until_toolbar_buttons_are_ready(self):
ready_flag = Value()
self.dialog.editor._links["set_ready_flag"] = ready_flag.set # noqa
self.dialog.run_javascript_after_toolbar_ready("pycmd('set_ready_flag');")
wait_until(ready_flag.has_been_set)
# preview button doesn't have an id, so find by label
def click_preview_button(self):
self.eval_js("""
document.evaluate("//button[text()='Preview']", document)
.iterateNext()
.click()
""")
def click_button(self, button_id):
self.eval_js(f"""
document.getElementById("{DOMAIN_PREFIX}{button_id}").click()
""")
def is_button_disabled(self, button_id):
return self.eval_js(f"""
document.getElementById("{DOMAIN_PREFIX}{button_id}").disabled
""")
##############################################################################
def test_edit_dialog_opens(setup): def test_edit_dialog_opens(setup):
@ -99,6 +154,30 @@ class TestPreviewDialog:
assert dialog._should_enable_next() is next_enabled assert dialog._should_enable_next() is next_enabled
class TestButtons:
@pytest.fixture
def manipulator(self, setup):
dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id)
return JavascriptDialogButtonManipulator(dialog)
def test_preview_button_can_be_clicked(self, manipulator, monkeypatch):
monkeypatch.setattr(manipulator.dialog, "show_preview", MagicMock())
manipulator.wait_until_toolbar_buttons_are_ready()
manipulator.click_preview_button()
wait_until(lambda: manipulator.dialog.show_preview.call_count == 1)
def test_addon_buttons_can_be_clicked(self, manipulator):
manipulator.wait_until_toolbar_buttons_are_ready()
manipulator.click_button(button_id="browse")
wait_until(lambda: get_dialog_instance("Browser") is not None)
def test_addon_buttons_get_disabled_enabled(self, setup, manipulator):
Edit.open_dialog_and_show_note_with_id(setup.note2_id)
manipulator.wait_until_toolbar_buttons_are_ready()
assert manipulator.is_button_disabled("previous") is False
assert manipulator.is_button_disabled("next") is True
class TestHistory: class TestHistory:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def cleanup(self): def cleanup(self):

View File

@ -1,4 +1,7 @@
import os
import aqt import aqt
import pytest
from conftest import ac, anki_connect_config_loaded, \ from conftest import ac, anki_connect_config_loaded, \
set_up_test_deck_and_test_model_and_two_notes, \ set_up_test_deck_and_test_model_and_two_notes, \
@ -33,6 +36,19 @@ class TestProfiles:
class TestExportImport: class TestExportImport:
# since Anki 2.1.50, exporting media for some wild reason
# will change the current working directory, which then gets removed.
# see `exporting.py`, ctrl-f `os.chdir(self.mediaDir)`
@pytest.fixture(autouse=True)
def current_working_directory_preserved(self):
cwd = os.getcwd()
yield
try:
os.getcwd()
except FileNotFoundError:
os.chdir(cwd)
def test_exportPackage(self, session_with_profile_loaded, setup): def test_exportPackage(self, session_with_profile_loaded, setup):
filename = session_with_profile_loaded.base + "/export.apkg" filename = session_with_profile_loaded.base + "/export.apkg"
ac.exportPackage(deck="test_deck", path=filename) ac.exportPackage(deck="test_deck", path=filename)

17
tox.ini
View File

@ -40,25 +40,26 @@
# LIBGL_ALWAYS_INDIRECT=1 # LIBGL_ALWAYS_INDIRECT=1
# QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu" # QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu"
# QT_DEBUG_PLUGINS=1 # QT_DEBUG_PLUGINS=1
# ANKIDEV=true # ANKIDEV=1
[tox] [tox]
minversion = 3.24 minversion = 3.24
skipsdist = true skipsdist = true
skip_install = true skip_install = true
envlist = py38-anki{45,46,47,48,49} envlist = py38-anki{45,46,47,48,49},py39-anki{50qt5,50qt6}
[testenv] [testenv]
commands = commands =
xvfb-run python -m pytest {posargs} env HOME={envtmpdir}/home xvfb-run python -m pytest {posargs}
setenv = setenv =
HOME={envdir}/home anki50qt6: DISABLE_QT5_COMPAT=1
allowlist_externals = allowlist_externals =
env
xvfb-run xvfb-run
deps = deps =
pytest==7.1.1 pytest==7.1.1
pytest-forked==1.4.0 pytest-forked==1.4.0
pytest-anki @ git+https://github.com/oakkitten/pytest-anki.git@17d19043 pytest-anki @ git+https://github.com/oakkitten/pytest-anki.git@a0d27aa5
anki45: anki==2.1.45 anki45: anki==2.1.45
anki45: aqt==2.1.45 anki45: aqt==2.1.45
@ -74,3 +75,9 @@ deps =
anki49: anki==2.1.49 anki49: anki==2.1.49
anki49: aqt==2.1.49 anki49: aqt==2.1.49
anki50qt5: anki==2.1.50
anki50qt5: aqt[qt5]==2.1.50
anki50qt6: anki==2.1.50
anki50qt6: aqt[qt6]==2.1.50