This commit is contained in:
熊潇 2025-06-06 15:10:15 +08:00
commit a67798d2b7
23 changed files with 17867 additions and 0 deletions

95
.gitignore vendored Normal file
View File

@ -0,0 +1,95 @@
# Byte-compiled / optimized / profiling files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.install-record.txt
MANIFEST
# PyInstaller
# Usually these files are created by pyinstaller command
build/
dist/
*.spec
# Installer logs
pip-log.txt
pipwin.log
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# Django
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask
instance/
.webassets-cache
# Scrapy project settings
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery beat schedule file
celerybeat-schedule
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# VSCode
.vscode/

14
LICENSE Normal file
View File

@ -0,0 +1,14 @@
Copyright 2016-2019 Alex Yatskov
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.

8415
README-zh.md Normal file

File diff suppressed because it is too large Load Diff

4582
README.md Normal file

File diff suppressed because it is too large Load Diff

13
link.sh Executable file
View File

@ -0,0 +1,13 @@
#!/usr/bin/env bash
plugin_name=AnkiConnectDev
plugin_path_linux=~/.local/share/Anki2/addons21
plugin_path_mac=~/Library/Application\ Support/Anki2/addons21
if [ -d "$plugin_path_linux" ]; then
ln -s -f $(pwd)/plugin $plugin_path_linux/$plugin_name
fi
if [ -d "$plugin_path_mac" ]; then
ln -s -f $(pwd)/plugin $plugin_path_mac/$plugin_name
fi

3
package.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/bash
git clean -xdf
7za a AnkiConnect.zip ./plugin/*

2177
plugin/__init__.py Normal file

File diff suppressed because it is too large Load Diff

8
plugin/config.json Normal file
View File

@ -0,0 +1,8 @@
{
"apiKey": null,
"apiLogPath": null,
"webBindAddress": "127.0.0.1",
"webBindPort": 8765,
"webCorsOriginList": ["http://localhost"],
"ignoreOriginList": []
}

1
plugin/config.md Normal file
View File

@ -0,0 +1 @@
Read the documentation on the [AnkiConnect](https://foosoft.net/projects/anki-connect/) project page for details.

458
plugin/edit.py Normal file
View File

@ -0,0 +1,458 @@
import aqt
import aqt.editor
import aqt.browser.previewer
from aqt import gui_hooks
from aqt.qt import Qt, QKeySequence, QShortcut, QCloseEvent, QMainWindow
from aqt.utils import restoreGeom, saveGeom, tooltip
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
# * has Previous/Back buttons to navigate the history of the dialog
# * has a Browse button to open the history in the Browser
# * has no bar with the Close button
#
# To register in Anki's dialog system:
# > from .edit import Edit
# > Edit.register_with_anki()
#
# To (re)open (note_id is an integer):
# > Edit.open_dialog_and_show_note_with_id(note_id)
DOMAIN_PREFIX = "foosoft.ankiconnect."
def get_note_by_note_id(note_id):
return aqt.mw.col.get_note(note_id)
def is_card_suspended(card):
return card.queue == QUEUE_TYPE_SUSPENDED
def filter_valid_note_ids(note_ids):
return aqt.mw.col.db.list(
"select id from notes where id in " + ids2str(note_ids)
)
##############################################################################
class DecentPreviewer(aqt.browser.previewer.MultiCardPreviewer):
class Adapter:
def get_current_card(self): raise NotImplementedError
def can_select_previous_card(self): raise NotImplementedError
def can_select_next_card(self): raise NotImplementedError
def select_previous_card(self): raise NotImplementedError
def select_next_card(self): raise NotImplementedError
def __init__(self, adapter: Adapter):
super().__init__(parent=None, mw=aqt.mw, on_close=lambda: None) # noqa
self.adapter = adapter
self.last_card_id = 0
def card(self):
return self.adapter.get_current_card()
def card_changed(self):
current_card_id = self.adapter.get_current_card().id
changed = self.last_card_id != current_card_id
self.last_card_id = current_card_id
return changed
# the check if we can select next/previous card is needed because
# the buttons sometimes get disabled a tad too late
# and can still be pressed by user.
# this is likely due to Anki sometimes delaying rendering of cards
# in order to avoid rendering them too fast?
def _on_prev_card(self):
if self.adapter.can_select_previous_card():
self.adapter.select_previous_card()
self.render_card()
def _on_next_card(self):
if self.adapter.can_select_next_card():
self.adapter.select_next_card()
self.render_card()
def _should_enable_prev(self):
return self.showing_answer_and_can_show_question() or \
self.adapter.can_select_previous_card()
def _should_enable_next(self):
return self.showing_question_and_can_show_answer() or \
self.adapter.can_select_next_card()
def _render_scheduled(self):
super()._render_scheduled() # noqa
self._updateButtons()
def showing_answer_and_can_show_question(self):
return self._state == "answer" and not self._show_both_sides
def showing_question_and_can_show_answer(self):
return self._state == "question"
class ReadyCardsAdapter(DecentPreviewer.Adapter):
def __init__(self, cards):
self.cards = cards
self.current = 0
def get_current_card(self):
return self.cards[self.current]
def can_select_previous_card(self):
return self.current > 0
def can_select_next_card(self):
return self.current < len(self.cards) - 1
def select_previous_card(self):
self.current -= 1
def select_next_card(self):
self.current += 1
##############################################################################
# store note ids instead of notes, as note objects don't implement __eq__ etc
class History:
number_of_notes_to_keep_in_history = 25
def __init__(self):
self.note_ids = []
def append(self, note):
if note.id in self.note_ids:
self.note_ids.remove(note.id)
self.note_ids.append(note.id)
self.note_ids = self.note_ids[-self.number_of_notes_to_keep_in_history:]
def has_note_to_left_of(self, note):
return note.id in self.note_ids and note.id != self.note_ids[0]
def has_note_to_right_of(self, note):
return note.id in self.note_ids and note.id != self.note_ids[-1]
def get_note_to_left_of(self, note):
note_id = self.note_ids[self.note_ids.index(note.id) - 1]
return get_note_by_note_id(note_id)
def get_note_to_right_of(self, note):
note_id = self.note_ids[self.note_ids.index(note.id) + 1]
return get_note_by_note_id(note_id)
def get_last_note(self): # throws IndexError if history empty
return get_note_by_note_id(self.note_ids[-1])
def remove_invalid_notes(self):
self.note_ids = filter_valid_note_ids(self.note_ids)
history = History()
# see method `find_cards` of `collection.py`
def trigger_search_for_dialog_history_notes(search_context, use_history_order):
search_context.search = " or ".join(
f"nid:{note_id}" for note_id in history.note_ids
)
if use_history_order:
search_context.order = f"""case c.nid {
" ".join(
f"when {note_id} then {n}"
for (n, note_id) in enumerate(reversed(history.note_ids))
)
} end asc"""
##############################################################################
# noinspection PyAttributeOutsideInit
class Edit(aqt.editcurrent.EditCurrent):
dialog_geometry_tag = DOMAIN_PREFIX + "edit"
dialog_registry_tag = DOMAIN_PREFIX + "Edit"
dialog_search_tag = DOMAIN_PREFIX + "edit.history"
# depending on whether the dialog already exists,
# 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):
QMainWindow.__init__(self, None, Qt.WindowType.Window)
self.form = aqt.forms.editcurrent.Ui_Dialog()
self.form.setupUi(self)
self.setWindowTitle("Edit")
self.setMinimumWidth(250)
self.setMinimumHeight(400)
restoreGeom(self, self.dialog_geometry_tag)
self.form.buttonBox.setVisible(False) # hides the Close button bar
self.setup_editor_buttons()
self.show()
self.bring_to_foreground()
history.remove_invalid_notes()
history.append(note)
self.show_note(note)
gui_hooks.operation_did_execute.append(self.on_operation_did_execute)
gui_hooks.editor_did_load_note.append(self.editor_did_load_note)
def reopen(self, note):
history.append(note)
self.show_note(note)
self.bring_to_foreground()
def cleanup(self):
gui_hooks.editor_did_load_note.remove(self.editor_did_load_note)
gui_hooks.operation_did_execute.remove(self.on_operation_did_execute)
self.editor.cleanup()
saveGeom(self, self.dialog_geometry_tag)
aqt.dialogs.markClosed(self.dialog_registry_tag)
def closeEvent(self, evt: QCloseEvent) -> None:
self.editor.call_after_note_saved(self.cleanup)
# This method (mostly) solves (at least on my Windows 10 machine) three issues
# with window activation. Without this not even too hacky a fix,
# * When dialog is opened from Yomichan *for the first time* since app start,
# the dialog opens in background (just like Browser does),
# but does not flash in taskbar (unlike Browser);
# * When dialog is opened, closed, *then main window is focused by clicking in it*,
# then dialog is opened from Yomichan again, same issue as above arises;
# * When dialog is restored from minimized state *and main window isn't minimized*,
# opening the dialog from Yomichan does not reliably focus it;
# sometimes it opens in foreground, sometimes in background.
# With this fix, windows nearly always appear in foreground in all three cases.
# In the case of the first two issues, strictly speaking, the fix is not ideal:
# the window appears in background first, and then quickly pops into foreground.
# It is not *too* unsightly, probably, no-one will notice this;
# still, a better solution must be possible. TODO find one!
#
# Note that operation systems, notably Windows, and desktop managers, may restrict
# background applications from raising windows to prevent them from interrupting
# what the user is currently doing. For details, see:
# https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setforegroundwindow#remarks
# https://doc.qt.io/qt-5/qwidget.html#activateWindow
# https://wiki.qt.io/Technical_FAQ#QWidget_::activateWindow.28.29_-_behavior_under_windows
def bring_to_foreground(self):
aqt.mw.app.processEvents()
self.activateWindow()
self.raise_()
#################################### hooks enabled during dialog lifecycle
def on_operation_did_execute(self, changes, handler):
if changes.note_text and handler is not self.editor:
self.reload_notes_after_user_action_elsewhere()
def editor_did_load_note(self, _editor):
self.enable_disable_next_and_previous_buttons()
###################################################### load & reload notes
# setting editor.card is required for the "Cards…" button to work properly
def show_note(self, note):
self.note = note
cards = note.cards()
self.editor.set_note(note)
self.editor.card = cards[0] if cards else None
if any(is_card_suspended(card) for card in cards):
tooltip("Some of the cards associated with this note "
"have been suspended", parent=self)
def reload_notes_after_user_action_elsewhere(self):
history.remove_invalid_notes()
try:
self.note.load() # this also updates the fields
except NotFoundError:
try:
self.note = history.get_last_note()
except IndexError:
self.cleanup()
return
self.show_note(self.note)
################################################################## actions
# search two times, one is to select the current note or its cards,
# and another to show the whole history, while keeping the above selection
# set sort column to our search tag, which:
# * prevents the column sort indicator from being shown
# * serves as a hint for us to show notes or cards in history order
# (user can then click on any of the column names
# to show history cards in the order of their choosing)
def show_browser(self, *_):
def search_input_select_all(hook_browser, *_):
hook_browser.form.searchEdit.lineEdit().selectAll()
gui_hooks.browser_did_change_row.remove(search_input_select_all)
gui_hooks.browser_did_change_row.append(search_input_select_all)
browser = aqt.dialogs.open("Browser", aqt.mw)
browser.table._state.sort_column = self.dialog_search_tag # noqa
browser.table._set_sort_indicator() # noqa
browser.search_for(f"nid:{self.note.id}")
browser.table.select_all()
browser.search_for(self.dialog_search_tag)
def show_preview(self, *_):
if cards := self.note.cards():
previewer = DecentPreviewer(ReadyCardsAdapter(cards))
previewer.open()
return previewer
else:
tooltip("No cards found", parent=self)
return None
def show_previous(self, *_):
if history.has_note_to_left_of(self.note):
self.show_note(history.get_note_to_left_of(self.note))
def show_next(self, *_):
if history.has_note_to_right_of(self.note):
self.show_note(history.get_note_to_right_of(self.note))
################################################## button and hotkey setup
def setup_editor_buttons(self):
gui_hooks.editor_did_init.append(self.add_preview_button)
gui_hooks.editor_did_init_buttons.append(self.add_right_hand_side_buttons)
# 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)
# * 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)
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,
cmd=DOMAIN_PREFIX + cmd,
id=DOMAIN_PREFIX + cmd,
func=function,
label=f"&nbsp;&nbsp;{label}&nbsp;&nbsp;",
tip=f"{tip} ({keys})",
keys=keys,
)
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 = not(history.has_note_to_left_of(self.note))
disable_next = not(history.has_note_to_right_of(self.note))
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)};
""")
##########################################################################
@classmethod
def browser_will_search(cls, search_context):
if search_context.search == cls.dialog_search_tag:
trigger_search_for_dialog_history_notes(
search_context=search_context,
use_history_order=cls.dialog_search_tag ==
search_context.browser.table._state.sort_column # noqa
)
@classmethod
def register_with_anki(cls):
if cls.dialog_registry_tag not in aqt.dialogs._dialogs: # noqa
aqt.dialogs.register_dialog(cls.dialog_registry_tag, cls)
gui_hooks.browser_will_search.append(cls.browser_will_search)
@classmethod
def open_dialog_and_show_note_with_id(cls, note_id): # raises NotFoundError
note = get_note_by_note_id(note_id)
return aqt.dialogs.open(cls.dialog_registry_tag, note)

107
plugin/util.py Normal file
View File

@ -0,0 +1,107 @@
# Copyright 2016-2021 Alex Yatskov
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# 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 os
import sys
import anki
import anki.sync
import aqt
import enum
import itertools
#
# Utilities
#
class MediaType(enum.Enum):
Audio = 1
Video = 2
Picture = 3
def download(url):
client = anki.sync.AnkiRequestsClient()
client.timeout = setting('webTimeout') / 1000
resp = client.get(url)
if resp.status_code != 200:
raise Exception('{} download failed with return code {}'.format(url, resp.status_code))
return client.streamContent(resp)
def api(*versions):
def decorator(func):
setattr(func, 'versions', versions)
setattr(func, 'api', True)
return func
return decorator
def cardQuestion(card):
if getattr(card, 'question', None) is None:
return card._getQA()['q']
return card.question()
def cardAnswer(card):
if getattr(card, 'answer', None) is None:
return card._getQA()['a']
return card.answer()
DEFAULT_CONFIG = {
'apiKey': None,
'apiLogPath': None,
'apiPollInterval': 25,
'apiVersion': 6,
'webBacklog': 5,
'webBindAddress': os.getenv('ANKICONNECT_BIND_ADDRESS', '127.0.0.1'),
'webBindPort': 8765,
'webCorsOrigin': os.getenv('ANKICONNECT_CORS_ORIGIN', None),
'webCorsOriginList': ['http://localhost'],
'ignoreOriginList': [],
'webTimeout': 10000,
}
def setting(key):
try:
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")
# ref https://docs.python.org/3.12/library/itertools.html#itertools.batched
if sys.version_info >= (3, 12):
batched = itertools.batched
else:
def batched(iterable, n):
iterator = iter(iterable)
while True:
batch = tuple(itertools.islice(iterator, n))
if not batch:
break
yield batch

301
plugin/web.py Normal file
View File

@ -0,0 +1,301 @@
# Copyright 2016-2021 Alex Yatskov
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# 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 json
import jsonschema
import select
import socket
from . import util
#
# WebRequest
#
class WebRequest:
def __init__(self, method, headers, body):
self.method = method
self.headers = headers
self.body = body
#
# WebClient
#
class WebClient:
def __init__(self, sock, handler):
self.sock = sock
self.handler = handler
self.readBuff = bytes()
self.writeBuff = bytes()
def advance(self, recvSize=1024):
if self.sock is None:
return False
rlist, wlist = select.select([self.sock], [self.sock], [], 0)[:2]
self.sock.settimeout(5.0)
if rlist:
while True:
try:
msg = self.sock.recv(recvSize)
except (ConnectionResetError, socket.timeout):
self.close()
return False
if not msg:
self.close()
return False
self.readBuff += msg
req, length = self.parseRequest(self.readBuff)
if req is not None:
self.readBuff = self.readBuff[length:]
self.writeBuff += self.handler(req)
break
if wlist and self.writeBuff:
try:
length = self.sock.send(self.writeBuff)
self.writeBuff = self.writeBuff[length:]
if not self.writeBuff:
self.close()
return False
except:
self.close()
return False
return True
def close(self):
if self.sock is not None:
self.sock.close()
self.sock = None
self.readBuff = bytes()
self.writeBuff = bytes()
def parseRequest(self, data):
parts = data.split('\r\n\r\n'.encode('utf-8'), 1)
if len(parts) == 1:
return None, 0
lines = parts[0].split('\r\n'.encode('utf-8'))
method = None
if len(lines) > 0:
request_line_parts = lines[0].split(' '.encode('utf-8'))
method = request_line_parts[0].upper() if len(request_line_parts) > 0 else None
headers = {}
for line in lines[1:]:
pair = line.split(': '.encode('utf-8'))
headers[pair[0].lower()] = pair[1] if len(pair) > 1 else None
headerLength = len(parts[0]) + 4
bodyLength = int(headers.get('content-length'.encode('utf-8'), 0))
totalLength = headerLength + bodyLength
if totalLength > len(data):
return None, 0
body = data[headerLength : totalLength]
return WebRequest(method, headers, body), totalLength
#
# WebServer
#
class WebServer:
def __init__(self, handler):
self.handler = handler
self.clients = []
self.sock = None
def advance(self):
if self.sock is not None:
self.acceptClients()
self.advanceClients()
def acceptClients(self):
rlist = select.select([self.sock], [], [], 0)[0]
if not rlist:
return
clientSock = self.sock.accept()[0]
if clientSock is not None:
clientSock.setblocking(False)
self.clients.append(WebClient(clientSock, self.handlerWrapper))
def advanceClients(self):
self.clients = list(filter(lambda c: c.advance(), self.clients))
def listen(self):
self.close()
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.setblocking(False)
self.sock.bind((util.setting('webBindAddress'), util.setting('webBindPort')))
self.sock.listen(util.setting('webBacklog'))
def handlerWrapper(self, req):
allowed, corsOrigin = self.allowOrigin(req)
if req.method == b'OPTIONS':
body = ''.encode('utf-8')
headers = self.buildHeaders(corsOrigin, body)
if b'access-control-request-private-network' in req.headers and (
req.headers[b'access-control-request-private-network'] == b'true'):
# include this header so that if a public origin is included in the whitelist,
# then browsers won't fail requests due to the private network access check
headers.append(['Access-Control-Allow-Private-Network', 'true'])
return self.buildResponse(headers, body)
try:
params = json.loads(req.body.decode('utf-8'))
jsonschema.validate(params, request_schema)
except (ValueError, jsonschema.ValidationError) as e:
if allowed:
if len(req.body) == 0:
body = json.dumps({"apiVersion": f"AnkiConnect v.{util.setting('apiVersion')}"}).encode('utf-8')
else:
reply = format_exception_reply(util.setting('apiVersion'), e)
body = json.dumps(reply).encode('utf-8')
headers = self.buildHeaders(corsOrigin, body)
return self.buildResponse(headers, body)
else:
params = {} # trigger the 403 response below
if allowed or params.get('action', '') == 'requestPermission':
if params.get('action', '') == 'requestPermission':
params['params'] = params.get('params', {})
params['params']['allowed'] = allowed
params['params']['origin'] = b'origin' in req.headers and req.headers[b'origin'].decode() or ''
if not allowed :
corsOrigin = params['params']['origin']
body = json.dumps(self.handler(params)).encode('utf-8')
headers = self.buildHeaders(corsOrigin, body)
else :
headers = [
['HTTP/1.1 403 Forbidden', None],
['Access-Control-Allow-Origin', corsOrigin],
['Access-Control-Allow-Headers', '*']
]
body = ''.encode('utf-8')
return self.buildResponse(headers, body)
def allowOrigin(self, req):
# handle multiple cors origins by checking the 'origin'-header against the allowed origin list from the config
webCorsOriginList = util.setting('webCorsOriginList')
# keep support for deprecated 'webCorsOrigin' field, as long it is not removed
webCorsOrigin = util.setting('webCorsOrigin')
if webCorsOrigin:
webCorsOriginList.append(webCorsOrigin)
allowed = False
corsOrigin = 'http://localhost'
allowAllCors = '*' in webCorsOriginList # allow CORS for all domains
if allowAllCors:
corsOrigin = '*'
allowed = True
elif b'origin' in req.headers:
originStr = req.headers[b'origin'].decode()
if originStr in webCorsOriginList :
corsOrigin = originStr
allowed = True
elif 'http://localhost' in webCorsOriginList and (
originStr == 'http://127.0.0.1' or originStr == 'https://127.0.0.1' or # allow 127.0.0.1 if localhost allowed
originStr.startswith('http://127.0.0.1:') or originStr.startswith('http://127.0.0.1:') or
originStr.startswith('chrome-extension://') or originStr.startswith('moz-extension://') or originStr.startswith('safari-web-extension://') ) : # allow chrome, firefox and safari extension if localhost allowed
corsOrigin = originStr
allowed = True
else:
allowed = True
return allowed, corsOrigin
def buildHeaders(self, corsOrigin, body):
return [
['HTTP/1.1 200 OK', None],
['Content-Type', 'application/json'],
['Access-Control-Allow-Origin', corsOrigin],
['Access-Control-Allow-Headers', '*'],
['Content-Length', str(len(body))]
]
def buildResponse(self, headers, body):
resp = bytes()
for key, value in headers:
if value is None:
resp += '{}\r\n'.format(key).encode('utf-8')
else:
resp += '{}: {}\r\n'.format(key, value).encode('utf-8')
resp += '\r\n'.encode('utf-8')
resp += body
return resp
def close(self):
if self.sock is not None:
self.sock.close()
self.sock = None
for client in self.clients:
client.close()
self.clients = []
def format_success_reply(api_version, result):
if api_version <= 4:
return result
else:
return {"result": result, "error": None}
def format_exception_reply(_api_version, exception):
return {"result": None, "error": str(exception)}
request_schema = {
"type": "object",
"properties": {
"action": {"type": "string", "minLength": 1},
"version": {"type": "integer"},
"params": {"type": "object"},
},
"required": ["action"],
}

299
tests/conftest.py Normal file
View File

@ -0,0 +1,299 @@
import concurrent.futures
import time
from contextlib import contextmanager
from dataclasses import dataclass
import aqt.operations.note
import pytest
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, 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()
# wait for n seconds, while events are being processed
def wait(seconds):
milliseconds = int(seconds * 1000)
QtTest.QTest.qWait(milliseconds) # noqa
def wait_until(booleanish_function, at_most_seconds=30):
deadline = time.time() + at_most_seconds
while time.time() < deadline:
if booleanish_function():
return
wait(0.01)
raise Exception(f"Function {booleanish_function} never once returned "
f"a positive value in {at_most_seconds} seconds")
def delete_model(model_name):
model = ac.collection().models.byName(model_name)
ac.collection().models.remove(model["id"])
def close_all_dialogs_and_wait_for_them_to_run_closing_callbacks():
aqt.dialogs.closeAll(onsuccess=lambda: None)
wait_until(aqt.dialogs.allClosed)
def get_dialog_instance(name):
return aqt.dialogs._dialogs[name][1] # noqa
# waitress is a WSGI server that Anki starts to serve css etc to its web views.
# it seems to have a race condition issue;
# the main loop thread is trying to `select.select` the sockets
# which a worker thread is closing because of a dead connection.
# this is especially pronounced in tests,
# as we open and close windows rapidly--and so web views and their connections.
# this small patch makes waitress skip actually closing the sockets
# (unless the server is shutting down--if it is, loop exceptions are ignored).
# while the unclosed sockets might accumulate,
# this should not pose an issue in test environment.
# see https://github.com/Pylons/waitress/issues/374
@contextmanager
def waitress_patched_to_prevent_it_from_dying():
original_close = wasyncore.dispatcher.close
sockets_that_must_not_be_garbage_collected = [] # lists are thread-safe
def close(self):
if not aqt.mw.mediaServer.is_shutdown:
sockets_that_must_not_be_garbage_collected.append(self.socket)
self.socket = None
original_close(self)
with MonkeyPatch().context() as monkey:
monkey.setattr(wasyncore.dispatcher, "close", close)
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_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():
yield session
@contextmanager
def anki_connect_config_loaded(session, web_bind_port):
with session.addon_config_created(
package_name="plugin",
default_config=DEFAULT_CONFIG,
user_config={**DEFAULT_CONFIG, "webBindPort": web_bind_port}
):
yield
@contextmanager
def current_decks_and_models_etc_preserved():
deck_names_before = ac.deckNames()
model_names_before = ac.modelNames()
try:
yield
finally:
deck_names_after = ac.deckNames()
model_names_after = ac.modelNames()
deck_names_to_delete = {*deck_names_after} - {*deck_names_before}
model_names_to_delete = {*model_names_after} - {*model_names_before}
ac.deleteDecks(decks=deck_names_to_delete, cardsToo=True)
for model_name in model_names_to_delete:
delete_model(model_name)
ac.guiDeckBrowser()
@dataclass
class Setup:
deck_id: int
note1_id: int
note2_id: int
note1_card_ids: "list[int]"
note2_card_ids: "list[int]"
card_ids: "list[int]"
def set_up_test_deck_and_test_model_and_two_notes():
ac.createModel(
modelName="test_model",
inOrderFields=["field1", "field2"],
cardTemplates=[
{"Front": "{{field1}}", "Back": "{{field2}}"},
{"Front": "{{field2}}", "Back": "{{field1}}"}
],
css="* {}",
)
deck_id = ac.createDeck("test_deck")
note1_id = ac.addNote(dict(
deckName="test_deck",
modelName="test_model",
fields={"field1": "note1 field1", "field2": "note1 field2"},
tags={"tag1"},
))
note2_id = ac.addNote(dict(
deckName="test_deck",
modelName="test_model",
fields={"field1": "note2 field1", "field2": "note2 field2"},
tags={"tag2"},
))
note1_card_ids = ac.findCards(query=f"nid:{note1_id}")
note2_card_ids = ac.findCards(query=f"nid:{note2_id}")
card_ids = ac.findCards(query="deck:test_deck")
return Setup(
deck_id=deck_id,
note1_id=note1_id,
note2_id=note2_id,
note1_card_ids=note1_card_ids,
note2_card_ids=note2_card_ids,
card_ids=card_ids,
)
#############################################################################
def pytest_addoption(parser):
parser.addoption("--tear-down-profile-after-each-test",
action="store_true",
default=True)
parser.addoption("--no-tear-down-profile-after-each-test", "-T",
action="store_false",
dest="tear_down_profile_after_each_test")
def pytest_report_header(config):
if config.option.forked:
return "test isolation: perfect; each test is run in a separate process"
if config.option.tear_down_profile_after_each_test:
return "test isolation: good; user profile is torn down after each test"
else:
return "test isolation: poor; only newly created decks and models " \
"are cleaned up between tests"
@pytest.fixture(autouse=True)
def run_background_tasks_on_main_thread(request, monkeypatch): # noqa
"""
Makes background operations such as card deletion execute on main thread
and execute the callback immediately
"""
def run_in_background(task, on_done=None, kwargs=None):
future = concurrent.futures.Future()
try:
future.set_result(task(**kwargs if kwargs is not None else {}))
except BaseException as e:
future.set_exception(e)
if on_done is not None:
on_done(future)
monkeypatch.setattr(aqt.mw.taskman, "run_in_background", run_in_background)
# don't use run_background_tasks_on_main_thread for tests that don't run Anki
def pytest_generate_tests(metafunc):
if (
run_background_tasks_on_main_thread.__name__ in metafunc.fixturenames
and session_scope_empty_session.__name__ not in metafunc.fixturenames
):
metafunc.fixturenames.remove(run_background_tasks_on_main_thread.__name__)
@pytest.fixture(scope="session")
def session_scope_empty_session():
with empty_anki_session_started() as session:
yield session
@pytest.fixture(scope="session")
def session_scope_session_with_profile_loaded(session_scope_empty_session):
with profile_created_and_loaded(session_scope_empty_session):
yield session_scope_empty_session
@pytest.fixture
def session_with_profile_loaded(session_scope_empty_session, request):
"""
Like anki_session fixture from pytest-anki, but:
* Default profile is loaded
* It's relying on session-wide app instance so that
it can be used without forking every test;
this can be useful to speed up tests and also
to examine Anki's stdout/stderr, which is not visible with forking.
* If command line option --no-tear-down-profile-after-each-test is passed,
only the newly created decks and models are deleted.
Otherwise, the profile is completely torn down after each test.
Tearing down the profile is significantly slower.
"""
if request.config.option.tear_down_profile_after_each_test:
with profile_created_and_loaded(session_scope_empty_session):
yield session_scope_empty_session
else:
session = request.getfixturevalue(
session_scope_session_with_profile_loaded.__name__
)
with current_decks_and_models_etc_preserved():
yield session
@pytest.fixture
def setup(session_with_profile_loaded):
"""
Like session_with_profile_loaded, but also:
* Added are:
* A deck `test_deck`
* A model `test_model` with fields `filed1` and `field2`
and two cards per note
* Two notes with two valid cards each using the above deck and model
* Edit dialog is registered with dialog manager
* Any dialogs, if open, are safely closed on exit
"""
Edit.register_with_anki()
yield set_up_test_deck_and_test_model_and_two_notes()
close_all_dialogs_and_wait_for_them_to_run_closing_callbacks()

93
tests/test_cards.py Executable file
View File

@ -0,0 +1,93 @@
import pytest
from anki.errors import NotFoundError # noqa
from conftest import ac
def test_findCards(setup):
card_ids = ac.findCards(query="deck:test_deck")
assert len(card_ids) == 4
class TestEaseFactors:
def test_setEaseFactors(self, setup):
result = ac.setEaseFactors(cards=setup.card_ids, easeFactors=[4200] * 4)
assert result == [True] * 4
def test_setEaseFactors_with_invalid_card_id(self, setup):
result = ac.setEaseFactors(cards=[123], easeFactors=[4200])
assert result == [False]
def test_getEaseFactors(self, setup):
ac.setEaseFactors(cards=setup.card_ids, easeFactors=[4200] * 4)
result = ac.getEaseFactors(cards=setup.card_ids)
assert result == [4200] * 4
def test_getEaseFactors_with_invalid_card_id(self, setup):
assert ac.getEaseFactors(cards=[123]) == [None]
class TestSuspending:
def test_suspend(self, setup):
assert ac.suspend(cards=setup.card_ids) is True
def test_suspend_fails_with_incorrect_id(self, setup):
with pytest.raises(NotFoundError):
assert ac.suspend(cards=[123])
def test_areSuspended_returns_False_for_regular_cards(self, setup):
result = ac.areSuspended(cards=setup.card_ids)
assert result == [False] * 4
def test_areSuspended_returns_True_for_suspended_cards(self, setup):
ac.suspend(setup.card_ids)
result = ac.areSuspended(cards=setup.card_ids)
assert result == [True] * 4
def test_areDue_returns_True_for_new_cards(setup):
result = ac.areDue(cards=setup.card_ids)
assert result == [True] * 4
def test_getIntervals(setup):
ac.getIntervals(cards=setup.card_ids, complete=False)
ac.getIntervals(cards=setup.card_ids, complete=True)
def test_cardsToNotes(setup):
result = ac.cardsToNotes(cards=setup.card_ids)
assert {*result} == {setup.note1_id, setup.note2_id}
class TestCardInfo:
def test_with_valid_ids(self, setup):
result = ac.cardsInfo(cards=setup.card_ids)
assert [item["cardId"] for item in result] == setup.card_ids
def test_with_incorrect_id(self, setup):
result = ac.cardsInfo(cards=[123])
assert result == [{}]
def test_forgetCards(setup):
ac.forgetCards(cards=setup.card_ids)
def test_relearnCards(setup):
ac.relearnCards(cards=setup.card_ids)
class TestAnswerCards:
def test_answerCards(self, setup):
ac.scheduler().reset()
answers = [
{"cardId": a, "ease": b} for a, b in zip(setup.card_ids, [2, 1, 4, 3])
]
result = ac.answerCards(answers)
assert result == [True] * 4
def test_answerCards_with_invalid_card_id(self, setup):
ac.scheduler().reset()
result = ac.answerCards([{"cardId": 123, "ease": 2}])
assert result == [False]

74
tests/test_decks.py Executable file
View File

@ -0,0 +1,74 @@
import pytest
from conftest import ac
def test_deckNames(session_with_profile_loaded):
result = ac.deckNames()
assert result == ["Default"]
def test_deckNamesAndIds(session_with_profile_loaded):
result = ac.deckNamesAndIds()
assert result == {"Default": 1}
def test_createDeck(session_with_profile_loaded):
ac.createDeck("foo")
assert {*ac.deckNames()} == {"Default", "foo"}
def test_changeDeck(setup):
ac.changeDeck(cards=setup.card_ids, deck="bar")
assert "bar" in ac.deckNames()
def test_deleteDeck(setup):
before = ac.deckNames()
ac.deleteDecks(decks=["test_deck"], cardsToo=True)
after = ac.deckNames()
assert {*before} - {*after} == {"test_deck"}
def test_deleteDeck_must_be_called_with_cardsToo_set_to_True_on_later_api(setup):
with pytest.raises(Exception):
ac.deleteDecks(decks=["test_deck"])
with pytest.raises(Exception):
ac.deleteDecks(decks=["test_deck"], cardsToo=False)
def test_getDeckConfig(session_with_profile_loaded):
result = ac.getDeckConfig(deck="Default")
assert result["name"] == "Default"
def test_saveDeckConfig(session_with_profile_loaded):
config = ac.getDeckConfig(deck="Default")
result = ac.saveDeckConfig(config=config)
assert result is True
def test_setDeckConfigId(session_with_profile_loaded):
result = ac.setDeckConfigId(decks=["Default"], configId=1)
assert result is True
def test_cloneDeckConfigId(session_with_profile_loaded):
result = ac.cloneDeckConfigId(cloneFrom=1, name="test")
assert isinstance(result, int)
def test_removedDeckConfigId(session_with_profile_loaded):
new_config_id = ac.cloneDeckConfigId(cloneFrom=1, name="test")
assert ac.removeDeckConfigId(configId=new_config_id) is True
def test_removedDeckConfigId_fails_with_invalid_id(session_with_profile_loaded):
new_config_id = ac.cloneDeckConfigId(cloneFrom=1, name="test")
assert ac.removeDeckConfigId(configId=new_config_id) is True
assert ac.removeDeckConfigId(configId=new_config_id) is False
def test_getDeckStats(session_with_profile_loaded):
result = ac.getDeckStats(decks=["Default"])
assert list(result.values())[0]["name"] == "Default"

253
tests/test_edit.py Normal file
View File

@ -0,0 +1,253 @@
from dataclasses import dataclass
from unittest.mock import MagicMock
import aqt.operations.note
import pytest
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):
Edit.open_dialog_and_show_note_with_id(setup.note1_id)
def test_edit_dialog_opens_only_once(setup):
dialog1 = Edit.open_dialog_and_show_note_with_id(setup.note1_id)
dialog2 = Edit.open_dialog_and_show_note_with_id(setup.note1_id)
assert dialog1 is dialog2
def test_edit_dialog_fails_to_open_with_invalid_note(setup):
with pytest.raises(Exception):
Edit.open_dialog_and_show_note_with_id(123)
class TestBrowser:
@staticmethod
def get_selected_card_ids():
return get_dialog_instance("Browser").table.get_selected_card_ids()
def test_dialog_opens(self, setup):
dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id)
dialog.show_browser()
def test_selects_cards_of_last_note(self, setup):
Edit.open_dialog_and_show_note_with_id(setup.note1_id)
Edit.open_dialog_and_show_note_with_id(setup.note2_id).show_browser()
assert {*self.get_selected_card_ids()} == {*setup.note2_card_ids}
def test_selects_cards_of_note_before_last_after_previous_button_pressed(self, setup):
Edit.open_dialog_and_show_note_with_id(setup.note1_id)
dialog = Edit.open_dialog_and_show_note_with_id(setup.note2_id)
def verify_that_the_table_shows_note2_cards_then_note1_cards():
get_dialog_instance("Browser").table.select_all()
assert {*self.get_selected_card_ids()[:2]} == {*setup.note2_card_ids}
assert {*self.get_selected_card_ids()[2:]} == {*setup.note1_card_ids}
dialog.show_previous()
dialog.show_browser()
assert {*self.get_selected_card_ids()} == {*setup.note1_card_ids}
verify_that_the_table_shows_note2_cards_then_note1_cards()
dialog.show_next()
dialog.show_browser()
assert {*self.get_selected_card_ids()} == {*setup.note2_card_ids}
verify_that_the_table_shows_note2_cards_then_note1_cards()
class TestPreviewDialog:
def test_opens(self, setup):
edit_dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id)
edit_dialog.show_preview()
@pytest.fixture
def dialog(self, setup):
edit_dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id)
preview_dialog: DecentPreviewer = edit_dialog.show_preview()
def press_next_button(times=0):
for _ in range(times):
preview_dialog._last_render = 0 # render without delay
preview_dialog._on_next()
preview_dialog.press_next_button = press_next_button
yield preview_dialog
@pytest.mark.parametrize(
"next_button_presses, current_card, "
"showing_question_only, previous_enabled, next_enabled",
[
pytest.param(0, 0, True, False, True,
id="next button pressed 0 times; first card, question"),
pytest.param(1, 0, False, True, True,
id="next button pressed 1 time; first card, answer"),
pytest.param(2, 1, True, True, True,
id="next button pressed 2 times; second card, question"),
pytest.param(3, 1, False, True, False,
id="next button pressed 3 times; second card, answer"),
pytest.param(4, 1, False, True, False,
id="next button pressed 4 times; second card still, answer"),
]
)
def test_navigation(self, dialog, next_button_presses, current_card,
showing_question_only, previous_enabled, next_enabled):
dialog.press_next_button(times=next_button_presses)
assert dialog.adapter.current == current_card
assert dialog.showing_question_and_can_show_answer() is showing_question_only
assert dialog._should_enable_prev() is previous_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:
@pytest.fixture(autouse=True)
def cleanup(self):
history.note_ids = []
def test_single_note(self, setup):
assert history.note_ids == []
Edit.open_dialog_and_show_note_with_id(setup.note1_id)
assert history.note_ids == [setup.note1_id]
def test_two_notes(self, setup):
Edit.open_dialog_and_show_note_with_id(setup.note1_id)
Edit.open_dialog_and_show_note_with_id(setup.note2_id)
assert history.note_ids == [setup.note1_id, setup.note2_id]
def test_old_note_reopened(self, setup):
Edit.open_dialog_and_show_note_with_id(setup.note1_id)
Edit.open_dialog_and_show_note_with_id(setup.note2_id)
Edit.open_dialog_and_show_note_with_id(setup.note1_id)
assert history.note_ids == [setup.note2_id, setup.note1_id]
def test_navigation(self, setup):
dialog = Edit.open_dialog_and_show_note_with_id(setup.note1_id)
Edit.open_dialog_and_show_note_with_id(setup.note2_id)
dialog.show_previous()
assert dialog.note.id == setup.note1_id
dialog.show_previous()
assert dialog.note.id == setup.note1_id
dialog.show_next()
assert dialog.note.id == setup.note2_id
dialog.show_next()
assert dialog.note.id == setup.note2_id
class TestNoteDeletionElsewhere:
@pytest.fixture
def delete_note(self, run_background_tasks_on_main_thread):
"""
Yields a function that accepts a single note id and deletes the note,
running the required hooks in sync
"""
return (
lambda note_id: aqt.operations.note
.remove_notes(parent=None, note_ids=[note_id]) # noqa
.run_in_background()
)
@staticmethod
def edit_dialog_is_open():
return aqt.dialogs._dialogs[Edit.dialog_registry_tag][1] is not None # noqa
@pytest.fixture
def dialog(self, setup):
Edit.open_dialog_and_show_note_with_id(setup.note1_id)
yield Edit.open_dialog_and_show_note_with_id(setup.note2_id)
def test_one_of_the_history_notes_is_deleted_and_dialog_stays(self,
setup, dialog, delete_note):
assert dialog.note.id == setup.note2_id
delete_note(setup.note2_id)
assert self.edit_dialog_is_open()
assert dialog.note.id == setup.note1_id
def test_all_of_the_history_notes_are_deleted_and_dialog_closes(self,
setup, dialog, delete_note):
delete_note(setup.note1_id)
delete_note(setup.note2_id)
assert not self.edit_dialog_is_open()

139
tests/test_graphical.py Executable file
View File

@ -0,0 +1,139 @@
import pytest
from unittest import mock
from conftest import ac, anki_version, wait_until, \
close_all_dialogs_and_wait_for_them_to_run_closing_callbacks, \
get_dialog_instance
def test_guiBrowse(setup):
ac.guiBrowse()
def test_guiDeckBrowser(setup):
ac.guiDeckBrowser()
# todo executing this test without running background tasks on main thread
# rarely causes media server (`aqt.mediasrv`) to fail:
# its `run` method raises OSError: invalid file descriptor.
# this can cause other tests to fail to tear down;
# particularly, any dialogs with editor may fail to close
# due to their trying to save the note first, which is done via web view,
# which fails to complete due to corrupt media server. investigate?
def test_guiCheckDatabase(setup, run_background_tasks_on_main_thread):
ac.guiCheckDatabase()
def test_guiDeckOverview(setup):
assert ac.guiDeckOverview(name="test_deck") is True
def test_guiImportFile(setup):
if anki_version >= (2, 1, 52):
with mock.patch('aqt.import_export.importing.prompt_for_file_then_import') as mock_prompt_for_file_then_import:
mock_prompt_for_file_then_import.return_value = True
ac.guiImportFile()
class TestAddCards:
note = {
"deckName": "test_deck",
"modelName": "Basic",
"fields": {"Front": "new front1", "Back": "new back1"},
"tags": ["tag1"]
}
# an actual small image, you can see it if you run the test with GUI
# noinspection SpellCheckingInspection
base64_gif = "R0lGODlhBQAEAHAAACwAAAAABQAEAIH///8AAAAAAAAAAAACB0QMqZcXDwoAOw=="
picture = {
"picture": [
{
"data": base64_gif,
"filename": "smiley.gif",
"fields": ["Front"],
}
]
}
@staticmethod
def click_on_add_card_dialog_save_button():
dialog = get_dialog_instance("AddCards")
dialog.addButton.click()
# todo previously, these tests were verifying
# that the return value of `guiAddCards` is `int`.
# while it is indeed `int`, on modern Anki it is also always a `0`,
# so we consider it useless. update documentation?
def test_without_note(self, setup):
ac.guiAddCards()
def test_with_note(self, setup):
ac.guiAddCards(note=self.note)
self.click_on_add_card_dialog_save_button()
close_all_dialogs_and_wait_for_them_to_run_closing_callbacks()
assert len(ac.findCards(query="new")) == 1
def test_with_note_and_a_picture(self, setup):
ac.guiAddCards(note={**self.note, **self.picture})
self.click_on_add_card_dialog_save_button()
close_all_dialogs_and_wait_for_them_to_run_closing_callbacks()
assert len(ac.findCards(query="new")) == 1
assert ac.retrieveMediaFile(filename="smiley.gif") == self.base64_gif
class TestReviewActions:
@pytest.fixture
def reviewing_started(self, setup):
assert ac.guiDeckReview(name="test_deck") is True
def test_startCardTimer(self, reviewing_started):
assert ac.guiStartCardTimer() is True
def test_guiShowQuestion(self, reviewing_started):
assert ac.guiShowQuestion() is True
assert ac.reviewer().state == "question"
def test_guiShowAnswer(self, reviewing_started):
assert ac.guiShowAnswer() is True
assert ac.reviewer().state == "answer"
def test_guiAnswerCard(self, reviewing_started):
ac.guiShowAnswer()
reviews_before = ac.cardReviews(deck="test_deck", startID=0)
assert ac.guiAnswerCard(ease=4) is True
reviews_after = ac.cardReviews(deck="test_deck", startID=0)
assert len(reviews_after) == len(reviews_before) + 1
def test_guiUndo(self, reviewing_started):
ac.guiShowAnswer()
reviews_before = ac.cardReviews(deck="test_deck", startID=0)
assert ac.guiAnswerCard(ease=4) is True
reviews_after_answer = ac.cardReviews(deck="test_deck", startID=0)
assert len(reviews_after_answer) == len(reviews_before) + 1
assert ac.guiUndo() is True
reviews_after_undo = ac.cardReviews(deck="test_deck", startID=0)
assert len(reviews_after_undo) == len(reviews_before)
class TestSelectedNotes:
def test_with_valid_deck_query(self, setup):
ac.guiBrowse(query="deck:test_deck")
wait_until(ac.guiSelectedNotes)
assert ac.guiSelectedNotes()[0] in {setup.note1_id, setup.note2_id}
def test_with_invalid_deck_query(self, setup):
ac.guiBrowse(query="deck:test_deck")
wait_until(ac.guiSelectedNotes)
ac.guiBrowse(query="deck:invalid")
wait_until(lambda: not ac.guiSelectedNotes())

56
tests/test_media.py Executable file
View File

@ -0,0 +1,56 @@
import base64
import os.path
from conftest import ac
FILENAME = "_test.txt"
BASE64_DATA_1 = base64.b64encode(b"test 1").decode("ascii")
BASE64_DATA_2 = base64.b64encode(b"test 2").decode("ascii")
def store_one_media_file():
return ac.storeMediaFile(filename=FILENAME, data=BASE64_DATA_1)
def store_two_media_files():
filename_1 = ac.storeMediaFile(filename=FILENAME, data=BASE64_DATA_1)
filename_2 = ac.storeMediaFile(filename=FILENAME, data=BASE64_DATA_2,
deleteExisting=False)
return filename_1, filename_2
##############################################################################
def test_storeMediaFile_one_file(session_with_profile_loaded):
filename_1 = store_one_media_file()
assert FILENAME == filename_1
def test_storeMediaFile_two_files_with_the_same_name(session_with_profile_loaded):
filename_1, filename_2 = store_two_media_files()
assert FILENAME == filename_1 != filename_2
def test_retrieveMediaFile(session_with_profile_loaded):
store_one_media_file()
result = ac.retrieveMediaFile(filename=FILENAME)
assert result == BASE64_DATA_1
def test_getMediaFilesNames(session_with_profile_loaded):
filenames = store_two_media_files()
result = ac.getMediaFilesNames(pattern="_tes*.txt")
assert {*filenames} == {*result}
def test_deleteMediaFile(session_with_profile_loaded):
filename_1, filename_2 = store_two_media_files()
ac.deleteMediaFile(filename=filename_1)
assert ac.retrieveMediaFile(filename=filename_1) is False
assert ac.getMediaFilesNames(pattern="_tes*.txt") == [filename_2]
def test_getMediaDirPath(session_with_profile_loaded):
assert os.path.isdir(ac.getMediaDirPath())

77
tests/test_misc.py Executable file
View File

@ -0,0 +1,77 @@
import os
import aqt
import pytest
from conftest import ac, anki_connect_config_loaded, \
set_up_test_deck_and_test_model_and_two_notes, \
current_decks_and_models_etc_preserved, wait
# version is retrieved from config
def test_version(session_with_profile_loaded):
with anki_connect_config_loaded(
session=session_with_profile_loaded,
web_bind_port=0,
):
assert ac.version() == 6
def test_reloadCollection(setup):
ac.reloadCollection()
def test_apiReflect(setup):
result = ac.apiReflect(
scopes=["actions", "invalidType"],
actions=["apiReflect", "invalidMethod"]
)
assert result == {
"scopes": ["actions"],
"actions": ["apiReflect"]
}
class TestProfiles:
def test_getProfiles(self, session_with_profile_loaded):
result = ac.getProfiles()
assert result == ["test_user"]
# waiting a little while gets rid of the cryptic warning:
# Qt warning: QXcbConnection: XCB error: 8 (BadMatch), sequence: 658,
# resource id: 2097216, major code: 42 (SetInputFocus), minor code: 0
def test_loadProfile(self, session_with_profile_loaded):
aqt.mw.unloadProfileAndShowProfileManager()
wait(0.1)
ac.loadProfile(name="test_user")
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)
def test_importPackage(self, session_with_profile_loaded):
filename = session_with_profile_loaded.base + "/export.apkg"
with current_decks_and_models_etc_preserved():
set_up_test_deck_and_test_model_and_two_notes()
ac.exportPackage(deck="test_deck", path=filename)
with current_decks_and_models_etc_preserved():
assert "test_deck" not in ac.deckNames()
ac.importPackage(path=filename)
assert "test_deck" in ac.deckNames()

295
tests/test_models.py Executable file
View File

@ -0,0 +1,295 @@
from conftest import ac
from plugin import anki_version
def test_modelNames(setup):
result = ac.modelNames()
assert "test_model" in result
def test_modelNamesAndIds(setup):
result = ac.modelNamesAndIds()
assert isinstance(result["test_model"], int)
def test_modelFieldNames(setup):
result = ac.modelFieldNames(modelName="test_model")
assert result == ["field1", "field2"]
def test_modelFieldDescriptions(setup):
result = ac.modelFieldDescriptions(modelName="test_model")
assert result == ["", ""]
def test_modelFieldFonts(setup):
result = ac.modelFieldFonts(modelName="test_model")
assert result == {
"field1": {
"font": "Arial",
"size": 20,
},
"field2": {
"font": "Arial",
"size": 20,
},
}
def test_modelFieldsOnTemplates(setup):
result = ac.modelFieldsOnTemplates(modelName="test_model")
assert result == {
"Card 1": [["field1"], ["field2"]],
"Card 2": [["field2"], ["field1"]],
}
class TestCreateModel:
createModel_kwargs = {
"modelName": "test_model_foo",
"inOrderFields": ["field1", "field2"],
"cardTemplates": [{"Front": "{{field1}}", "Back": "{{field2}}"}],
}
def test_createModel_without_css(self, session_with_profile_loaded):
ac.createModel(**self.createModel_kwargs)
def test_createModel_with_css(self, session_with_profile_loaded):
ac.createModel(**self.createModel_kwargs, css="* {}")
class TestStyling:
def test_modelStyling(self, setup):
result = ac.modelStyling(modelName="test_model")
assert result == {"css": "* {}"}
def test_updateModelStyling(self, setup):
ac.updateModelStyling(model={
"name": "test_model",
"css": "* {color: red;}"
})
assert ac.modelStyling(modelName="test_model") == {
"css": "* {color: red;}"
}
class TestModelTemplates:
def test_modelTemplates(self, setup):
result = ac.modelTemplates(modelName="test_model")
assert result == {
"Card 1": {"Front": "{{field1}}", "Back": "{{field2}}"},
"Card 2": {"Front": "{{field2}}", "Back": "{{field1}}"}
}
def test_updateModelTemplates(self, setup):
ac.updateModelTemplates(model={
"name": "test_model",
"templates": {"Card 1": {"Front": "{{field1}}", "Back": "foo"}}
})
assert ac.modelTemplates(modelName="test_model") == {
"Card 1": {"Front": "{{field1}}", "Back": "foo"},
"Card 2": {"Front": "{{field2}}", "Back": "{{field1}}"}
}
def test_findAndReplaceInModels(setup):
ac.findAndReplaceInModels(
modelName="test_model",
findText="}}",
replaceText="}}!",
front=True,
back=False,
css=False,
)
ac.findAndReplaceInModels(
modelName="test_model",
findText="}}",
replaceText="}}?",
front=True,
back=True,
css=False,
)
ac.findAndReplaceInModels(
modelName="test_model",
findText="}",
replaceText="color: blue;}",
front=False,
back=False,
css=True,
)
assert ac.modelTemplates(modelName="test_model") == {
"Card 1": {"Front": "{{field1}}?!", "Back": "{{field2}}?"},
"Card 2": {"Front": "{{field2}}?!", "Back": "{{field1}}?"}
}
assert ac.modelStyling(modelName="test_model") == {
"css": "* {color: blue;}"
}
class TestModelTemplates:
def test_modelTemplateRename(self, setup):
ac.modelTemplateRename(
modelName="test_model",
oldTemplateName="Card 1",
newTemplateName="Card 1 Renamed",
)
result = ac.modelTemplates(modelName="test_model")
assert result == {
"Card 1 Renamed": {"Front": "{{field1}}", "Back": "{{field2}}"},
"Card 2": {"Front": "{{field2}}", "Back": "{{field1}}"}
}
def test_modelTemplateReposition(self, setup):
# There currently isn't a way to test for order, so this is just a
# smoke test for now
ac.modelTemplateReposition(
modelName="test_model",
templateName="Card 1",
index=1,
)
def test_modelTemplateAdd(self, setup):
ac.modelTemplateAdd(
modelName="test_model",
template={
"Name": "Card 3",
"Front": "{{field1}} Card 3",
"Back": "{{field2}}",
}
)
result = ac.modelTemplates(modelName="test_model")
assert result == {
"Card 1": {"Front": "{{field1}}", "Back": "{{field2}}"},
"Card 2": {"Front": "{{field2}}", "Back": "{{field1}}"},
"Card 3": {"Front": "{{field1}} Card 3", "Back": "{{field2}}"},
}
def test_modelTemplateRemove(self, setup):
ac.modelTemplateRemove(
modelName="test_model",
templateName="Card 2"
)
result = ac.modelTemplates(modelName="test_model")
assert result == {
"Card 1": {"Front": "{{field1}}", "Back": "{{field2}}"},
}
class TestModelFieldNames:
def test_modelFieldRename(self, setup):
ac.modelFieldRename(
modelName="test_model",
oldFieldName="field1",
newFieldName="foo",
)
result = ac.modelFieldNames(modelName="test_model")
assert result == ["foo", "field2"]
def test_modelFieldReposition(self, setup):
ac.modelFieldReposition(
modelName="test_model",
fieldName="field1",
index=2,
)
result = ac.modelFieldNames(modelName="test_model")
assert result == ["field2", "field1"]
def test_modelFieldAdd(self, setup):
ac.modelFieldAdd(
modelName="test_model",
fieldName="Foo",
)
result = ac.modelFieldNames(modelName="test_model")
assert result == ["field1", "field2", "Foo"]
def test_modelFieldAdd_with_index(self, setup):
ac.modelFieldAdd(
modelName="test_model",
fieldName="Foo",
index=1,
)
result = ac.modelFieldNames(modelName="test_model")
assert result == ["field1", "Foo", "field2"]
def test_modelFieldRemove(self, setup):
# makes sure that the front template always has a field,
# and makes sure that the front template of the cards are not the same
ac.updateModelTemplates(model={
"name": "test_model",
"templates": {"Card 1": {"Front": "{{field2}} {{field2}}", "Back": "foo"}}
})
ac.modelFieldRemove(
modelName="test_model",
fieldName="field1",
)
result = ac.modelFieldNames(modelName="test_model")
assert result == ["field2"]
def test_modelFieldSetFont(self, setup):
ac.modelFieldSetFont(
modelName="test_model",
fieldName="field1",
font="Courier",
)
result = ac.modelFieldFonts(modelName="test_model")
assert result == {
"field1": {
"font": "Courier",
"size": 20,
},
"field2": {
"font": "Arial",
"size": 20,
},
}
def test_modelFieldSetFontSize(self, setup):
ac.modelFieldSetFontSize(
modelName="test_model",
fieldName="field2",
fontSize=16,
)
result = ac.modelFieldFonts(modelName="test_model")
assert result == {
"field1": {
"font": "Arial",
"size": 20,
},
"field2": {
"font": "Arial",
"size": 16,
},
}
def test_modelFieldSetDescription(self, setup):
set_desc = ac.modelFieldSetDescription(
modelName="test_model",
fieldName="field1",
description="test description",
)
result = ac.modelFieldDescriptions(modelName="test_model")
if anki_version < (2, 1, 50):
assert not set_desc
assert result == ["", ""]
else:
assert set_desc
assert result == ["test description", ""]

163
tests/test_notes.py Executable file
View File

@ -0,0 +1,163 @@
import pytest
from anki.errors import NotFoundError # noqa
from conftest import ac
def make_note(*, front="front1", allow_duplicates=False):
note = {
"deckName": "test_deck",
"modelName": "Basic",
"fields": {"Front": front, "Back": "back1"},
"tags": ["tag1"],
}
if allow_duplicates:
return {**note, "options": {"allowDuplicate": True}}
else:
return note
##############################################################################
class TestNoteAddition:
def test_addNote(self, setup):
result = ac.addNote(note=make_note())
assert isinstance(result, int)
def test_addNote_will_not_allow_duplicates_by_default(self, setup):
ac.addNote(make_note())
with pytest.raises(Exception, match="it is a duplicate"):
ac.addNote(make_note())
def test_addNote_will_allow_duplicates_if_options_say_aye(self, setup):
ac.addNote(make_note())
ac.addNote(make_note(allow_duplicates=True))
def test_addNotes(self, setup):
result = ac.addNotes(notes=[
make_note(front="foo"),
make_note(front="bar"),
make_note(front="foo"),
])
assert len(result) == 3
assert isinstance(result[0], int)
assert isinstance(result[1], int)
assert result[2] is None
def test_bug164(self, setup):
note = {
"deckName": "test_deck",
"modelName": "Basic",
"fields": {"Front": " Whitespace\n", "Back": ""},
"options": {"allowDuplicate": False, "duplicateScope": "deck"}
}
ac.addNote(note=note)
with pytest.raises(Exception, match="it is a duplicate"):
ac.addNote(note=note)
def test_notesInfo(setup):
result = ac.notesInfo(notes=[setup.note1_id])
assert len(result) == 1
assert result[0]["noteId"] == setup.note1_id
assert result[0]["tags"] == ["tag1"]
assert result[0]["fields"]["field1"]["value"] == "note1 field1"
class TestTags:
def test_addTags(self, setup):
ac.addTags(notes=[setup.note1_id], tags="tag2")
tags = ac.notesInfo(notes=[setup.note1_id])[0]["tags"]
assert {*tags} == {"tag1", "tag2"}
def test_getTags(self, setup):
result = ac.getTags()
assert {*result} == {"tag1", "tag2"}
def test_removeTags(self, setup):
ac.removeTags(notes=[setup.note2_id], tags="tag2")
assert ac.notesInfo(notes=[setup.note2_id])[0]["tags"] == []
def test_replaceTags(self, setup):
ac.replaceTags(notes=[setup.note1_id, 123],
tag_to_replace="tag1", replace_with_tag="foo")
notes_info = ac.notesInfo(notes=[setup.note1_id])
assert notes_info[0]["tags"] == ["foo"]
def test_replaceTagsInAllNotes(self, setup):
ac.replaceTagsInAllNotes(tag_to_replace="tag1", replace_with_tag="foo")
notes_info = ac.notesInfo(notes=[setup.note1_id])
assert notes_info[0]["tags"] == ["foo"]
def test_clearUnusedTags(self, setup):
ac.removeTags(notes=[setup.note2_id], tags="tag2")
ac.clearUnusedTags()
assert ac.getTags() == ["tag1"]
def test_updateNoteTags_and_getNoteTags(self, setup):
ac.updateNoteTags(note=setup.note1_id, tags="footag")
assert ac.getNoteTags(note=setup.note1_id) == ["footag"]
ac.updateNoteTags(note=setup.note1_id, tags=["foo", "bar", "baz"])
assert len(ac.getNoteTags(note=setup.note1_id)) == 3
class TestUpdateNoteFields:
def test_updateNoteFields(self, setup):
new_fields = {"field1": "foo", "field2": "bar"}
good_note = {"id": setup.note1_id, "fields": new_fields}
ac.updateNoteFields(note=good_note)
notes_info = ac.notesInfo(notes=[setup.note1_id])
assert notes_info[0]["fields"]["field2"]["value"] == "bar"
def test_updateNoteFields_will_not_update_invalid_notes(self, setup):
bad_note = {"id": 123, "fields": make_note()["fields"]}
with pytest.raises(NotFoundError):
ac.updateNoteFields(note=bad_note)
class TestUpdateNote:
def test_updateNote(self, setup):
new_fields = {"field1": "frontbar", "field2": "backbar"}
new_tags = ["foobar"]
good_note = {"id": setup.note1_id, "fields": new_fields, "tags": new_tags}
ac.updateNote(note=good_note)
notes_info = ac.notesInfo(notes=[setup.note1_id])
assert notes_info[0]["fields"]["field2"]["value"] == "backbar"
assert notes_info[0]["tags"] == ["foobar"]
def test_updateNote_requires_either_fields_or_tags(self, setup):
with pytest.raises(Exception, match="ust provide"):
ac.updateNote(note={"id": setup.note1_id})
class TestCanAddNotes:
foo_bar_notes = [make_note(front="foo"), make_note(front="bar")]
def test_canAddNotes(self, setup):
result = ac.canAddNotes(notes=self.foo_bar_notes)
assert result == [True, True]
def test_canAddNotes_will_not_add_duplicates_if_options_do_not_say_aye(self, setup):
ac.addNotes(notes=self.foo_bar_notes)
notes = [
make_note(front="foo"),
make_note(front="baz"),
make_note(front="foo", allow_duplicates=True)
]
result = ac.canAddNotes(notes=notes)
assert result == [False, True, True]
def test_findNotes(setup):
result = ac.findNotes(query="deck:test_deck")
assert {*result} == {setup.note1_id, setup.note2_id}
def test_deleteNotes(setup):
ac.deleteNotes(notes=[setup.note1_id, setup.note2_id])
result = ac.findNotes(query="deck:test_deck")
assert result == []

198
tests/test_server.py Normal file
View File

@ -0,0 +1,198 @@
import json
import multiprocessing
import time
import urllib.error
import urllib.request
from contextlib import contextmanager
from dataclasses import dataclass
from functools import partial
import pytest
from pytest_anki._launch import anki_running # noqa
from pytest_anki._util import find_free_port # noqa
from plugin import AnkiConnect
from tests.conftest import wait_until, \
empty_anki_session_started, \
anki_connect_config_loaded, \
profile_created_and_loaded
@contextmanager
def function_running_in_a_process(context, function):
process = context.Process(target=function)
process.start()
try:
yield process
finally:
process.join()
# todo stop the server?
@contextmanager
def anki_connect_web_server_started():
plugin = AnkiConnect()
plugin.startWebServer()
yield plugin
@dataclass
class Client:
port: int
@staticmethod
def make_request(action, **params):
return {"action": action, "params": params, "version": 6}
def send_request(self, action, **params):
request_data = self.make_request(action, **params)
json_bytes = json.dumps(request_data).encode("utf-8")
return json.loads(self.send_bytes(json_bytes))
def send_bytes(self, bytes, headers={}): # noqa
request_url = f"http://localhost:{self.port}"
request = urllib.request.Request(request_url, bytes, headers)
response = urllib.request.urlopen(request).read()
return response
def wait_for_web_server_to_come_live(self, at_most_seconds=30):
deadline = time.time() + at_most_seconds
while time.time() < deadline:
try:
self.send_request("version")
return
except urllib.error.URLError:
time.sleep(0.01)
raise Exception(f"Anki-Connect web server did not come live "
f"in {at_most_seconds} seconds")
# spawning requires a top-level function for pickling
def external_anki_entry_function(web_bind_port, exit_event):
with empty_anki_session_started() as session:
with anki_connect_config_loaded(session, web_bind_port):
with anki_connect_web_server_started():
with profile_created_and_loaded(session):
wait_until(exit_event.is_set)
@contextmanager
def external_anki_running(process_run_method):
context = multiprocessing.get_context(process_run_method)
exit_event = context.Event()
web_bind_port = find_free_port()
function = partial(external_anki_entry_function, web_bind_port, exit_event)
with function_running_in_a_process(context, function) as process:
client = Client(port=web_bind_port)
client.wait_for_web_server_to_come_live()
try:
yield client
finally:
exit_event.set()
assert process.exitcode == 0
# if a Qt app was already launched in current process,
# launching a new Qt app, even from grounds up, fails or hangs.
# of course, this includes forked processes. therefore,
# * if launching without --forked, use the `spawn` process run method;
# * otherwise, use the `fork` method, as it is significantly faster.
# with --forked, each test has its fixtures assembled inside the fork,
# which means that when the test begins, Qt was never started in the fork.
@pytest.fixture(scope="module")
def external_anki(request):
"""
Runs Anki in an external process, with the plugin loaded and started.
On exit, neatly ends the process and makes sure its exit code is 0.
Yields a client that can send web request to the external process.
"""
with external_anki_running(
"fork" if request.config.option.forked else "spawn"
) as client:
yield client
##############################################################################
def test_successful_request(external_anki):
response = external_anki.send_request("version")
assert response == {"error": None, "result": 6}
def test_can_handle_multiple_requests(external_anki):
assert external_anki.send_request("version") == {"error": None, "result": 6}
assert external_anki.send_request("version") == {"error": None, "result": 6}
def test_multi_request(external_anki):
version_request = Client.make_request("version")
response = external_anki.send_request("multi", actions=[version_request] * 3)
assert response == {
"error": None,
"result": [{"error": None, "result": 6}] * 3
}
def test_request_with_empty_body_returns_version_banner(external_anki):
response = json.loads(external_anki.send_bytes(b""))
assert response == {
"apiVersion": "AnkiConnect v.6"
}
def test_failing_request_due_to_bad_arguments(external_anki):
response = external_anki.send_request("addNote", bad="request")
assert response["result"] is None
assert "unexpected keyword argument" in response["error"]
def test_failing_request_due_to_anki_raising_exception(external_anki):
response = external_anki.send_request("suspend", cards=[-123])
assert response["result"] is None
assert "Card was not found" in response["error"]
def test_failing_request_due_to_bad_encoding(external_anki):
response = json.loads(external_anki.send_bytes(b"\xe7\x8c"))
assert response["result"] is None
assert "can't decode" in response["error"]
def test_failing_request_due_to_bad_json(external_anki):
response = json.loads(external_anki.send_bytes(b'{1: 2}'))
assert response["result"] is None
assert "in double quotes" in response["error"]
def test_failing_request_due_to_json_root_not_being_an_object(external_anki):
response = json.loads(external_anki.send_bytes(b"1.2"))
assert response["result"] is None
assert "is not of type 'object'" in response["error"]
def test_failing_request_due_to_json_missing_wanted_properties(external_anki):
response = json.loads(external_anki.send_bytes(b"{}"))
assert response["result"] is None
assert "'action' is a required property" in response["error"]
def test_failing_request_due_to_json_properties_being_of_wrong_types(external_anki):
response = json.loads(external_anki.send_bytes(b'{"action": 1}'))
assert response["result"] is None
assert "1 is not of type 'string'" in response["error"]
def test_403_in_case_of_disallowed_origin(external_anki):
with pytest.raises(urllib.error.HTTPError, match="403"): # good request/json
json_bytes = json.dumps(Client.make_request("version")).encode("utf-8")
external_anki.send_bytes(json_bytes, headers={b"origin": b"foo"})
with pytest.raises(urllib.error.HTTPError, match="403"): # bad json
external_anki.send_bytes(b'{1: 2}', headers={b"origin": b"foo"})

46
tests/test_stats.py Executable file
View File

@ -0,0 +1,46 @@
from conftest import ac
def test_getNumCardsReviewedToday(setup):
result = ac.getNumCardsReviewedToday()
assert isinstance(result, int)
def test_getNumCardsReviewedByDay(setup):
result = ac.getNumCardsReviewedByDay()
assert isinstance(result, list)
def test_getCollectionStatsHTML(setup):
result = ac.getCollectionStatsHTML()
assert isinstance(result, str)
class TestReviews:
def test_zero_reviews_for_a_new_deck(self, setup):
assert ac.cardReviews(deck="test_deck", startID=0) == []
assert ac.getLatestReviewID(deck="test_deck") == 0
def test_some_reviews_for_a_reviewed_deck(self, setup):
ac.insertReviews(reviews=[
(456, setup.card_ids[0], -1, 3, 4, -60, 2500, 6157, 0),
(789, setup.card_ids[1], -1, 1, -60, -60, 0, 4846, 0)
])
assert len(ac.cardReviews(deck="test_deck", startID=0)) == 2
assert ac.getLatestReviewID(deck="test_deck") == 789
assert ac.getReviewsOfCards(cards=[setup.card_ids[0]]) == \
{
setup.card_ids[0]: [
{
"id": 456,
"usn": -1,
"ease": 3,
"ivl": 4,
"lastIvl": -60,
"factor": 2500,
"time": 6157,
"type": 0,
}
]
}