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 install -y pyqt5-dev-tools xvfb
- name: Setup Python
- name: Setup Python 3.8
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
run: pip install tox

1
.gitignore vendored
View File

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

View File

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

View File

@ -1,5 +1,6 @@
import aqt
import aqt.editor
import aqt.browser.previewer
from aqt import gui_hooks
from aqt.qt import QDialog, Qt, QKeySequence, QShortcut
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.utils import ids2str
from . import anki_version
# Edit dialog. Like Edit Current, but:
# * 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()`,
# the manager will call either the constructor or the `reopen` method
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)
self.form = aqt.forms.editcurrent.Ui_Dialog()
self.form.setupUi(self)
@ -225,8 +228,6 @@ class Edit(aqt.editcurrent.EditCurrent):
if changes.note_text and handler is not self.editor:
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):
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_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.remove(self.add_preview_button)
# taken from `setupEditor` of browser.py
# PreviewButton calls pycmd `preview`, which is hardcoded.
# copying _links is needed so that opening Anki's browser does not
# screw them up as they are apparently shared between instances?!
# * on Anki < 2.1.50, make the button via js (`setupEditor` of browser.py);
# also, make a copy of _links so that opening Anki's browser does not
# 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):
QShortcut(QKeySequence("Ctrl+Shift+P"), self, self.show_preview)
editor._links = editor._links.copy()
editor._links["preview"] = self.show_preview
editor.web.eval("""
$editorToolbar.then(({notetypeButtons}) =>
notetypeButtons.appendButton(
{component: editorToolbar.PreviewButton, id: 'preview'}
)
);
""")
if anki_version < (2, 1, 50):
editor._links = editor._links.copy()
editor.web.eval("""
$editorToolbar.then(({notetypeButtons}) =>
notetypeButtons.appendButton(
{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):
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):
button_html = editor.addButton(
icon=None,
@ -338,30 +376,34 @@ class Edit(aqt.editcurrent.EditCurrent):
keys=keys,
)
# adding class `btn` properly styles buttons when disabled
button_html = button_html.replace('class="', 'class="btn ')
button_html = button_html.replace('class="',
f'class="{extra_button_class} ')
buttons.append(button_html)
add("browse", self.show_browser, "Browse", "Browse", "Ctrl+F")
add("previous", self.show_previous, "&lt;", "Previous", "Alt+Left")
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 to_js(boolean):
return "true" if boolean else "false"
disable_previous = to_js(not(history.has_note_to_left_of(self.note)))
disable_next = to_js(not(history.has_note_to_right_of(self.note)))
disable_previous = not(history.has_note_to_left_of(self.note))
disable_next = not(history.has_note_to_right_of(self.note))
self.editor.web.eval(f"""
$editorToolbar.then(({{ toolbar }}) => {{
setTimeout(function() {{
document.getElementById("{DOMAIN_PREFIX}previous")
.disabled = {disable_previous};
document.getElementById("{DOMAIN_PREFIX}next")
.disabled = {disable_next};
}}, 1);
}});
self.run_javascript_after_toolbar_ready(f"""
document.getElementById("{DOMAIN_PREFIX}previous")
.disabled = {to_js(disable_previous)};
document.getElementById("{DOMAIN_PREFIX}next")
.disabled = {to_js(disable_next)};
""")
##########################################################################

View File

@ -14,6 +14,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os
import sys
import anki
import anki.sync
@ -83,3 +84,10 @@ def setting(key):
return aqt.mw.addonManager.getConfig(__name__).get(key, DEFAULT_CONFIG[key])
except:
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 pytest
from PyQt5 import QtTest
import anki.collection
from _pytest.monkeypatch import MonkeyPatch # noqa
from pytest_anki._launch import anki_running, temporary_user # noqa
from waitress import wasyncore
from plugin import AnkiConnect
from plugin import AnkiConnect, anki_version
from plugin.edit import Edit
from plugin.util import DEFAULT_CONFIG
try:
from PyQt6 import QtTest
except ImportError:
from PyQt5 import QtTest
ac = AnkiConnect()
@ -77,22 +82,33 @@ def waitress_patched_to_prevent_it_from_dying():
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
def empty_anki_session_started():
with waitress_patched_to_prevent_it_from_dying():
with anki_running(
qtbot=None, # noqa
enable_web_debugging=False,
profile_name="test_user",
) as session:
yield session
with anki_patched_to_prevent_backups():
with anki_running(
qtbot=None, # noqa
enable_web_debugging=False,
profile_name="test_user",
) as session:
yield session
@contextmanager
def profile_created_and_loaded(session):
with temporary_user(session.base, "test_user", "en_US"):
with session.profile_loaded():
aqt.mw.pm.profile["numBackups"] = 0 # don't try to do backups
yield session

View File

@ -30,10 +30,6 @@ def test_deleteDeck(setup):
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):
with pytest.raises(Exception):
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 pytest
from conftest import get_dialog_instance
from plugin.edit import Edit, DecentPreviewer, history
from conftest import get_dialog_instance, wait_until
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):
@ -99,6 +154,30 @@ class TestPreviewDialog:
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:
@pytest.fixture(autouse=True)
def cleanup(self):

View File

@ -1,4 +1,7 @@
import os
import aqt
import pytest
from conftest import ac, anki_connect_config_loaded, \
set_up_test_deck_and_test_model_and_two_notes, \
@ -33,6 +36,19 @@ class TestProfiles:
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):
filename = session_with_profile_loaded.base + "/export.apkg"
ac.exportPackage(deck="test_deck", path=filename)

19
tox.ini
View File

@ -40,25 +40,26 @@
# LIBGL_ALWAYS_INDIRECT=1
# QTWEBENGINE_CHROMIUM_FLAGS="--disable-gpu"
# QT_DEBUG_PLUGINS=1
# ANKIDEV=true
# ANKIDEV=1
[tox]
minversion = 3.24
skipsdist = 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]
commands =
xvfb-run python -m pytest {posargs}
env HOME={envtmpdir}/home xvfb-run python -m pytest {posargs}
setenv =
HOME={envdir}/home
anki50qt6: DISABLE_QT5_COMPAT=1
allowlist_externals =
env
xvfb-run
deps =
pytest==7.1.1
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: aqt==2.1.45
@ -73,4 +74,10 @@ deps =
anki48: aqt==2.1.48
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