From 7733e03360c847dcaf113eece191f8bb2fc89ba2 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 22 Dec 2019 19:28:36 +1000 Subject: [PATCH 01/42] make it possible to implement functionality in Rust --- .gitignore | 1 + Makefile | 19 ++- README.development | 11 ++ anki/__init__.py | 3 + anki/rsbridge.py | 9 ++ mypy.ini | 2 + rs/Cargo.lock | 316 +++++++++++++++++++++++++++++++++++++++++++++ rs/Cargo.toml | 20 +++ rs/rust-toolchain | 1 + rs/src/lib.rs | 31 +++++ 10 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 anki/rsbridge.py create mode 100644 rs/Cargo.lock create mode 100644 rs/Cargo.toml create mode 100644 rs/rust-toolchain create mode 100644 rs/src/lib.rs diff --git a/.gitignore b/.gitignore index 16ba7b97c..9872b4fd2 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ web/overview.js web/reviewer-bottom.js web/reviewer.js web/webview.js +rs/target diff --git a/Makefile b/Makefile index 07cf42554..4a1689a0e 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ MAKEFLAGS += --no-builtin-rules RUNARGS := .SUFFIXES: BLACKARGS := -t py36 anki aqt +RUSTARGS := --release --strip $(shell mkdir -p .build) @@ -66,6 +67,10 @@ RUNREQS := .build/pyrunreqs .build/jsreqs ./tools/typecheck-setup.sh touch $@ +.build/rustreqs: .build/pyrunreqs + pip install maturin + touch $@ + .build/jsreqs: ts/package.json (cd ts && npm i) touch $@ @@ -76,10 +81,15 @@ RUNREQS := .build/pyrunreqs .build/jsreqs TSDEPS := $(wildcard ts/src/*.ts) JSDEPS := $(patsubst ts/src/%.ts, web/%.js, $(TSDEPS)) +# Rust source +###################### + +RSDEPS := $(wildcard rs/src/*.rs) + # Building ###################### -BUILDDEPS := .build/ui .build/js +BUILDDEPS := .build/ui .build/js .build/rs .build/ui: $(RUNREQS) $(shell find designer -type f) ./tools/build_ui.sh @@ -89,6 +99,10 @@ BUILDDEPS := .build/ui .build/js (cd ts && npm run build) touch $@ +.build/rs: .build/rustreqs $(RUNREQS) $(RSDEPS) + (cd rs && maturin develop $(RUSTARGS)) + touch $@ + .PHONY: build clean build: $(BUILDDEPS) @@ -97,6 +111,7 @@ build: $(BUILDDEPS) clean: rm -rf .build rm -rf $(JSDEPS) + rm -rf rs/target # Running ###################### @@ -125,7 +140,7 @@ PYCHECKDEPS := $(BUILDDEPS) .build/pycheckreqs $(shell find anki aqt -name '*.py touch $@ .build/pylint: $(PYCHECKDEPS) - pylint -j 0 --rcfile=.pylintrc -f colorized --extension-pkg-whitelist=PyQt5 anki aqt + pylint -j 0 --rcfile=.pylintrc -f colorized --extension-pkg-whitelist=PyQt5,_ankirs anki aqt touch $@ .build/pyimports: $(PYCHECKDEPS) diff --git a/README.development b/README.development index 8f257c375..4c71b82fd 100644 --- a/README.development +++ b/README.development @@ -18,6 +18,17 @@ To start, make sure you have the following installed: - mpv - lame - npm + - your platform's C compiler, eg gcc, Xcode or Visual Studio 2017. + - GNU make + +Once the above are installed, install rustup from https://rustup.rs/. You can +save some time by choosing a custom installation, and selecting a toolkit of +'none' and 'minimal' tools. + +Next, build a Python virtual environment and activate it: + +$ python3 -m venv ~/pyenv +$ . ~/pyenv/bin/activate If the distro you are using has PyQt5 installed, make sure you have the PyQt5 WebEngine module and development tools (eg pyqt5-dev-tools) installed as well. diff --git a/anki/__init__.py b/anki/__init__.py index baea42e12..19468b6df 100644 --- a/anki/__init__.py +++ b/anki/__init__.py @@ -6,6 +6,9 @@ import sys from anki.storage import Collection +# temporary +from . import rsbridge + if sys.version_info[0] < 3 or sys.version_info[1] < 5: raise Exception("Anki requires Python 3.5+") diff --git a/anki/rsbridge.py b/anki/rsbridge.py new file mode 100644 index 000000000..cf05f5fef --- /dev/null +++ b/anki/rsbridge.py @@ -0,0 +1,9 @@ +import _ankirs # pytype: disable=import-error + + +class RSBridge: + def __init__(self): + self._bridge = _ankirs.Bridge() + assert self._bridge.cmd("") == "test" + +bridge = RSBridge() diff --git a/mypy.ini b/mypy.ini index 4f76feffc..91e7b46c6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -34,3 +34,5 @@ ignore_missing_imports = True ignore_missing_imports = True [mypy-jsonschema.*] ignore_missing_imports = True +[mypy-_ankirs] +ignore_missing_imports = True diff --git a/rs/Cargo.lock b/rs/Cargo.lock new file mode 100644 index 000000000..672910bf7 --- /dev/null +++ b/rs/Cargo.lock @@ -0,0 +1,316 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +[[package]] +name = "aho-corasick" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fb5e95d83b38284460a5fda7d6470aa0b8844d283a0b614b8535e880800d2d" +dependencies = [ + "memchr", +] + +[[package]] +name = "ankirs" +version = "0.1.0" +dependencies = [ + "pyo3", +] + +[[package]] +name = "autocfg" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2" + +[[package]] +name = "ctor" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd8ce37ad4184ab2ce004c33bf6379185d3b1c95801cab51026bd271bf68eedc" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "ghost" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a36606a68532b5640dc86bb1f33c64b45c4682aad4c50f3937b317ea387f3d6" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "indoc" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9553c1e16c114b8b77ebeb329e5f2876eed62a8d51178c8bc6bff0d65f98f8" +dependencies = [ + "indoc-impl", + "proc-macro-hack", +] + +[[package]] +name = "indoc-impl" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b714fc08d0961716390977cdff1536234415ac37b509e34e5a983def8340fb75" +dependencies = [ + "proc-macro-hack", + "proc-macro2", + "quote", + "syn", + "unindent", +] + +[[package]] +name = "inventory" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4cece20baea71d9f3435e7bbe9adf4765f091c5fe404975f844006964a71299" +dependencies = [ + "ctor", + "ghost", + "inventory-impl", +] + +[[package]] +name = "inventory-impl" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2869bf972e998977b1cb87e60df70341d48e48dca0823f534feb91ea44adaf9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itoa" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501266b7edd0174f8530248f87f99c88fbe60ca4ef3dd486835b8d8d53136f7f" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "libc" +version = "0.2.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d515b1f41455adea1313a4a2ac8a8a477634fbae63cc6100e3aebb207ce61558" + +[[package]] +name = "memchr" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88579771288728879b57485cc7d6b07d648c9f0141eb955f8ab7f9d45394468e" + +[[package]] +name = "num-traits" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c81ffc11c212fa327657cb19dd85eb7419e163b5b076bede2bdb5c974c07e4" +dependencies = [ + "autocfg", +] + +[[package]] +name = "paste" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "423a519e1c6e828f1e73b720f9d9ed2fa643dce8a7737fb43235ce0b41eeaa49" +dependencies = [ + "paste-impl", + "proc-macro-hack", +] + +[[package]] +name = "paste-impl" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4214c9e912ef61bf42b81ba9a47e8aad1b2ffaf739ab162bf96d1e011f54e6c5" +dependencies = [ + "proc-macro-hack", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecd45702f76d6d3c75a80564378ae228a85f0b59d2f3ed43c91b4a69eb2ebfc5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e470a8dc4aeae2dee2f335e8f533e2d4b347e1434e5671afc49b054592f27" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "pyo3" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f9df1468dddf8a59ec799cf3b930bb75ec09deabe875ba953e06c51d1077136" +dependencies = [ + "indoc", + "inventory", + "lazy_static", + "libc", + "num-traits", + "paste", + "pyo3cls", + "regex", + "serde", + "serde_json", + "spin", + "unindent", + "version_check", +] + +[[package]] +name = "pyo3-derive-backend" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f6e56fb3e97b344a8f87d036f94578399402c6b75949de6270cd07928f790b1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pyo3cls" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97452dcdf5941627ebc5c06664a07821fc7fc88d7515f02178193a8ebe316468" +dependencies = [ + "proc-macro2", + "pyo3-derive-backend", + "quote", + "syn", +] + +[[package]] +name = "quote" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053a8c8bcc71fcce321828dc897a98ab9760bef03a4fc36693c231e5b3216cfe" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc220bd33bdce8f093101afe22a037b8eb0e5af33592e6a9caafff0d4cb81cbd" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", + "thread_local", +] + +[[package]] +name = "regex-syntax" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11a7e20d1cce64ef2fed88b66d347f88bd9babb82845b2b858f3edbf59a4f716" + +[[package]] +name = "ryu" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa8506c1de11c9c4e4c38863ccbe02a305c8188e85a05a784c9e11e1c3910c8" + +[[package]] +name = "serde" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "414115f25f818d7dfccec8ee535d76949ae78584fc4f79a6f45a904bf8ab4449" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "128f9e303a5a29922045a830221b8f78ec74a5f544944f3d5984f8ec3895ef64" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c575e0cc52bdd09b47f330f646cf59afc586e9c4e3ccd6fc1f625b8ea1dad7" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "syn" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff0acdb207ae2fe6d5976617f887eb1e35a2ba52c13c7234c790960cdad9238" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + +[[package]] +name = "thread_local" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6b53e329000edc2b34dbe8545fd20e55a333362d0a321909685a19bd28c3f1b" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "unicode-xid" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826e7639553986605ec5979c7dd957c7895e93eabed50ab2ffa7f6128a75097c" + +[[package]] +name = "unindent" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63f18aa3b0e35fed5a0048f029558b1518095ffe2a0a31fb87c93dece93a4993" + +[[package]] +name = "version_check" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078775d0255232fb988e6fccf26ddc9d1ac274299aaedcedce21c6f72cc533ce" diff --git a/rs/Cargo.toml b/rs/Cargo.toml new file mode 100644 index 000000000..6638703a4 --- /dev/null +++ b/rs/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "ankirs" +version = "0.1.0" +edition = "2018" +authors = ["Ankitects Pty Ltd and contributors"] + +[dependencies] + + +[dependencies.pyo3] +version = "0.8.0" +features = ["extension-module"] + +[profile.release] +lto = true +codegen-units = 1 + +[lib] +name = "_ankirs" +crate-type = ["cdylib"] diff --git a/rs/rust-toolchain b/rs/rust-toolchain new file mode 100644 index 000000000..a9fe5202c --- /dev/null +++ b/rs/rust-toolchain @@ -0,0 +1 @@ +nightly-2019-12-15 diff --git a/rs/src/lib.rs b/rs/src/lib.rs new file mode 100644 index 000000000..b70f02282 --- /dev/null +++ b/rs/src/lib.rs @@ -0,0 +1,31 @@ +use pyo3::prelude::*; +use pyo3::exceptions; + +#[pyclass] +struct Bridge { +} + +#[pymethods] +impl Bridge { + + #[new] + fn new(obj: &PyRawObject) { + obj.init({ + Bridge { } + }); + } + + fn cmd(&mut self, request: String) -> PyResult { + Ok("test".to_string()) + .map_err(|e: std::io::Error| { + exceptions::Exception::py_err(format!("{:?}", e)) + }) + } +} + +#[pymodule] +fn _ankirs(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_class::()?; + + Ok(()) +} From ed6f58080b63c6a78a52c2145ee543ce797cdfd8 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 22 Dec 2019 19:46:40 +1000 Subject: [PATCH 02/42] rename workflow file --- .github/workflows/{pythonpackage.yml => checks.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{pythonpackage.yml => checks.yml} (100%) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/checks.yml similarity index 100% rename from .github/workflows/pythonpackage.yml rename to .github/workflows/checks.yml From 1ecb6f44340c0ae4ce11eaa8afaab7df2496e1e7 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 22 Dec 2019 19:53:05 +1000 Subject: [PATCH 03/42] venv fix for github --- .github/workflows/checks.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index cb3e23425..0f4ebb9f9 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -20,11 +20,10 @@ jobs: uses: actions/setup-node@v1 with: node-version: 12 - - name: Install dependencies - run: | - sudo apt install portaudio19-dev - pip install -r requirements.qt - name: Run checks run: | + sudo apt install portaudio19-dev + python${{ matrix.python-version }} -m venv ~/pyenv + . ~/pyenv/bin/activate + pip install -r requirements.qt make check - From 079657893cc65fb62cb0a08f8a55c266757c0b8c Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Sun, 22 Dec 2019 20:02:22 +1000 Subject: [PATCH 04/42] speed up CI build --- .github/workflows/checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 0f4ebb9f9..9ad6008d6 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -26,4 +26,4 @@ jobs: python${{ matrix.python-version }} -m venv ~/pyenv . ~/pyenv/bin/activate pip install -r requirements.qt - make check + make check RUSTARGS="" From f24a396c525bbdf2095b27b6901232d99417a684 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 23 Dec 2019 13:25:18 +1000 Subject: [PATCH 05/42] format --- anki/rsbridge.py | 1 + 1 file changed, 1 insertion(+) diff --git a/anki/rsbridge.py b/anki/rsbridge.py index cf05f5fef..9f0ac5e25 100644 --- a/anki/rsbridge.py +++ b/anki/rsbridge.py @@ -6,4 +6,5 @@ class RSBridge: self._bridge = _ankirs.Bridge() assert self._bridge.cmd("") == "test" + bridge = RSBridge() From 249e2a2da05693062324109f387cf0581feb0bd7 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 23 Dec 2019 14:42:14 +1000 Subject: [PATCH 06/42] split the rust code into a workspace --- rs/Cargo.lock | 10 +++++++--- rs/Cargo.toml | 18 ++---------------- rs/ankirs/Cargo.toml | 5 +++++ rs/ankirs/src/lib.rs | 1 + rs/pybridge/Cargo.toml | 13 +++++++++++++ rs/pybridge/src/lib.rs | 25 +++++++++++++++++++++++++ rs/src/lib.rs | 31 ------------------------------- 7 files changed, 53 insertions(+), 50 deletions(-) create mode 100644 rs/ankirs/Cargo.toml create mode 100644 rs/ankirs/src/lib.rs create mode 100644 rs/pybridge/Cargo.toml create mode 100644 rs/pybridge/src/lib.rs delete mode 100644 rs/src/lib.rs diff --git a/rs/Cargo.lock b/rs/Cargo.lock index 672910bf7..a8ec493ff 100644 --- a/rs/Cargo.lock +++ b/rs/Cargo.lock @@ -12,9 +12,6 @@ dependencies = [ [[package]] name = "ankirs" version = "0.1.0" -dependencies = [ - "pyo3", -] [[package]] name = "autocfg" @@ -163,6 +160,13 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "pybridge" +version = "0.1.0" +dependencies = [ + "pyo3", +] + [[package]] name = "pyo3" version = "0.8.4" diff --git a/rs/Cargo.toml b/rs/Cargo.toml index 6638703a4..d261ed4c3 100644 --- a/rs/Cargo.toml +++ b/rs/Cargo.toml @@ -1,20 +1,6 @@ -[package] -name = "ankirs" -version = "0.1.0" -edition = "2018" -authors = ["Ankitects Pty Ltd and contributors"] - -[dependencies] - - -[dependencies.pyo3] -version = "0.8.0" -features = ["extension-module"] +[workspace] +members = ["ankirs", "pybridge"] [profile.release] lto = true codegen-units = 1 - -[lib] -name = "_ankirs" -crate-type = ["cdylib"] diff --git a/rs/ankirs/Cargo.toml b/rs/ankirs/Cargo.toml new file mode 100644 index 000000000..1981374cc --- /dev/null +++ b/rs/ankirs/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "ankirs" +version = "0.1.0" +edition = "2018" +authors = ["Ankitects Pty Ltd and contributors"] diff --git a/rs/ankirs/src/lib.rs b/rs/ankirs/src/lib.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/rs/ankirs/src/lib.rs @@ -0,0 +1 @@ + diff --git a/rs/pybridge/Cargo.toml b/rs/pybridge/Cargo.toml new file mode 100644 index 000000000..0af13556b --- /dev/null +++ b/rs/pybridge/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "pybridge" +version = "0.1.0" +edition = "2018" +authors = ["Ankitects Pty Ltd and contributors"] + +[dependencies.pyo3] +version = "0.8.0" +features = ["extension-module"] + +[lib] +name = "_ankirs" +crate-type = ["cdylib"] diff --git a/rs/pybridge/src/lib.rs b/rs/pybridge/src/lib.rs new file mode 100644 index 000000000..c56231b73 --- /dev/null +++ b/rs/pybridge/src/lib.rs @@ -0,0 +1,25 @@ +use pyo3::exceptions; +use pyo3::prelude::*; + +#[pyclass] +struct Bridge {} + +#[pymethods] +impl Bridge { + #[new] + fn init(obj: &PyRawObject) { + obj.init({ Bridge {} }); + } + + fn cmd(&mut self, _request: String) -> PyResult { + Ok("test".to_string()) + .map_err(|e: std::io::Error| exceptions::Exception::py_err(format!("{:?}", e))) + } +} + +#[pymodule] +fn _ankirs(_py: Python, m: &PyModule) -> PyResult<()> { + m.add_class::()?; + + Ok(()) +} diff --git a/rs/src/lib.rs b/rs/src/lib.rs deleted file mode 100644 index b70f02282..000000000 --- a/rs/src/lib.rs +++ /dev/null @@ -1,31 +0,0 @@ -use pyo3::prelude::*; -use pyo3::exceptions; - -#[pyclass] -struct Bridge { -} - -#[pymethods] -impl Bridge { - - #[new] - fn new(obj: &PyRawObject) { - obj.init({ - Bridge { } - }); - } - - fn cmd(&mut self, request: String) -> PyResult { - Ok("test".to_string()) - .map_err(|e: std::io::Error| { - exceptions::Exception::py_err(format!("{:?}", e)) - }) - } -} - -#[pymodule] -fn _ankirs(_py: Python, m: &PyModule) -> PyResult<()> { - m.add_class::()?; - - Ok(()) -} From e893294ee42afc155d54449f99333caa9956f34d Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 23 Dec 2019 14:51:12 +1000 Subject: [PATCH 07/42] add rust checks, and clean up the makefile --- Makefile | 139 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 92 insertions(+), 47 deletions(-) diff --git a/Makefile b/Makefile index 4a1689a0e..4870cc295 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,8 @@ install: -xdg-mime default anki.desktop application/x-apkg @echo @echo "Install complete." + # fixme: _ankirs.so needs to be copied into system python env or + # 'maturin build' used uninstall: rm -rf ${DESTDIR}${PREFIX}/share/anki @@ -56,24 +58,43 @@ uninstall: # Prerequisites ###################### -RUNREQS := .build/pyrunreqs .build/jsreqs +RUNREQS := .build/py-run-deps .build/ts-deps -.build/pyrunreqs: requirements.txt +# Python prerequisites +###################### + +.build/py-run-deps: requirements.txt pip install -r $< - touch $@ + @touch $@ -.build/pycheckreqs: requirements.check .build/pyrunreqs +.build/py-check-reqs: requirements.check .build/py-run-deps pip install -r $< ./tools/typecheck-setup.sh - touch $@ + @touch $@ -.build/rustreqs: .build/pyrunreqs - pip install maturin - touch $@ +# TS prerequisites +###################### -.build/jsreqs: ts/package.json +.build/ts-deps: ts/package.json (cd ts && npm i) - touch $@ + @touch $@ + +# Rust prerequisites +###################### + +.build/rust-deps: .build/py-run-deps + pip install maturin + @touch $@ + +RUST_TOOLCHAIN := $(shell cat rs/rust-toolchain) + +.build/rs-fmt-deps: + rustup component add rustfmt-preview --toolchain $(RUST_TOOLCHAIN) + @touch $@ + +.build/rs-clippy-deps: + rustup component add clippy-preview --toolchain $(RUST_TOOLCHAIN) + @touch $@ # Typescript source ###################### @@ -84,7 +105,7 @@ JSDEPS := $(patsubst ts/src/%.ts, web/%.js, $(TSDEPS)) # Rust source ###################### -RSDEPS := $(wildcard rs/src/*.rs) +RSDEPS := $(wildcard rs/*/src/*.rs) # Building ###################### @@ -93,15 +114,15 @@ BUILDDEPS := .build/ui .build/js .build/rs .build/ui: $(RUNREQS) $(shell find designer -type f) ./tools/build_ui.sh - touch $@ + @touch $@ -.build/js: .build/jsreqs $(TSDEPS) +.build/js: .build/ts-deps $(TSDEPS) (cd ts && npm run build) - touch $@ + @touch $@ -.build/rs: .build/rustreqs $(RUNREQS) $(RSDEPS) - (cd rs && maturin develop $(RUSTARGS)) - touch $@ +.build/rs: .build/rust-deps $(RUNREQS) $(RSDEPS) + (cd rs/pybridge && maturin develop $(RUSTARGS)) + @touch $@ .PHONY: build clean @@ -124,61 +145,85 @@ run: build ###################### .PHONY: check -check: mypy pyimports pyfmt pytest pylint checkpretty +check: rs-test rs-fmt rs-clippy py-mypy py-test py-fmt py-imports py-lint ts-fmt # Checking python ###################### -PYCHECKDEPS := $(BUILDDEPS) .build/pycheckreqs $(shell find anki aqt -name '*.py' | grep -v buildhash.py) +PYCHECKDEPS := $(BUILDDEPS) .build/py-check-reqs $(shell find anki aqt -name '*.py' | grep -v buildhash.py) -.build/mypy: $(PYCHECKDEPS) +.build/py-mypy: $(PYCHECKDEPS) mypy anki aqt - touch $@ + @touch $@ .build/pytest: $(PYCHECKDEPS) $(wildcard tests/*.py) ./tools/tests.sh - touch $@ + @touch $@ -.build/pylint: $(PYCHECKDEPS) +.build/py-lint: $(PYCHECKDEPS) pylint -j 0 --rcfile=.pylintrc -f colorized --extension-pkg-whitelist=PyQt5,_ankirs anki aqt - touch $@ + @touch $@ -.build/pyimports: $(PYCHECKDEPS) - isort anki aqt --check # if this fails, run 'make fixpyimports' - touch $@ +.build/py-imports: $(PYCHECKDEPS) + isort anki aqt --check # if this fails, run 'make fix-py-imports' + @touch $@ -.build/pyfmt: $(PYCHECKDEPS) - black --check $(BLACKARGS) # if this fails, run 'make fixpyfmt' - touch $@ +.build/py-fmt: $(PYCHECKDEPS) + black --check $(BLACKARGS) # if this fails, run 'make fix-py-fmt' + @touch $@ -.PHONY: mypy pytest pylint pyimports pyfmt -mypy: .build/mypy -pytest: .build/pytest -pylint: .build/pylint -pyimports: .build/pyimports -pyfmt: .build/pyfmt +.PHONY: py-mypy py-test py-lint py-imports py-fmt +py-mypy: .build/py-mypy +py-test: .build/py-test +py-lint: .build/py-lint +py-imports: .build/py-imports +py-fmt: .build/py-fmt -.PHONY: fixpyimports fixpyfmt +.PHONY: fix-py-imports fix-py-fmt -fixpyimports: +fix-py-imports: isort anki aqt -fixpyfmt: +fix-py-fmt: black $(BLACKARGS) anki aqt +# Checking rust +###################### + +.build/rs-test: $(RSDEPS) + (cd rs/ankirs && cargo test) + @touch $@ + +.build/rs-fmt: .build/rs-fmt-deps $(RSDEPS) + (cd rs && cargo fmt -- --check) # if this fails, run 'make fix-rs-fmt' + @touch $@ + +.build/rs-clippy: .build/rs-clippy-deps $(RSDEPS) + (cd rs && cargo clippy -- -D warnings) + @touch $@ + +.PHONY: rs-test rs-fmt fix-rs-fmt rs-clippy + +rs-test: .build/rs-test +rs-fmt: .build/rs-fmt +rs-clippy: .build/rs-clippy + +fix-rs-fmt: + (cd rs && cargo fmt) + + # Checking typescript ###################### TSCHECKDEPS := $(BUILDDEPS) $(TSDEPS) -.build/checkpretty: $(TSCHECKDEPS) - (cd ts && npm run check-pretty) # if this fails, run 'make pretty' - touch $@ +.build/ts-fmt: $(TSCHECKDEPS) + (cd ts && npm run check-pretty) # if this fails, run 'make fix-ts-fmt' + @touch $@ -.build/pretty: $(TSCHECKDEPS) +.PHONY: fix-ts-fmt ts-fmt +ts-fmt: .build/ts-fmt + +fix-ts-fmt: (cd ts && npm run pretty) - touch $@ -.PHONY: pretty checkpretty -pretty: .build/pretty -checkpretty: .build/checkpretty From 252a0cb54f4d34adc66aec15b4360d7970abca85 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 13:33:44 +1000 Subject: [PATCH 08/42] use protobuf for python/rust bridge If we go ahead with betterproto on the Python side, it will mean bumping the minimum Python dependency to 3.7. --- .gitignore | 6 +- Makefile | 20 +- anki/collection.py | 7 +- anki/rsbridge.py | 29 ++- anki/storage.py | 5 +- proto/bridge.proto | 36 ++++ requirements.txt | 1 + rs/Cargo.lock | 449 +++++++++++++++++++++++++++++++++++++--- rs/ankirs/Cargo.toml | 8 + rs/ankirs/build.rs | 7 + rs/ankirs/src/bridge.rs | 84 ++++++++ rs/ankirs/src/err.rs | 16 ++ rs/ankirs/src/lib.rs | 3 + rs/pybridge/Cargo.toml | 3 + rs/pybridge/src/lib.rs | 20 +- rs/rustfmt.toml | 1 + 16 files changed, 649 insertions(+), 46 deletions(-) create mode 100644 proto/bridge.proto create mode 100644 rs/ankirs/build.rs create mode 100644 rs/ankirs/src/bridge.rs create mode 100644 rs/ankirs/src/err.rs create mode 100644 rs/rustfmt.toml diff --git a/.gitignore b/.gitignore index 9872b4fd2..d82845757 100644 --- a/.gitignore +++ b/.gitignore @@ -3,15 +3,18 @@ *\# *~ .*.swp -.DS_Store .build .coverage +.DS_Store .mypy_cache .pytype __pycache__ anki/buildhash.py +anki/proto aqt/forms locale +rs/ankirs/src/proto.rs +rs/target tools/runanki.system ts/node_modules web/deckbrowser.js @@ -20,4 +23,3 @@ web/overview.js web/reviewer-bottom.js web/reviewer.js web/webview.js -rs/target diff --git a/Makefile b/Makefile index 4870cc295..ed9edf687 100644 --- a/Makefile +++ b/Makefile @@ -96,6 +96,11 @@ RUST_TOOLCHAIN := $(shell cat rs/rust-toolchain) rustup component add clippy-preview --toolchain $(RUST_TOOLCHAIN) @touch $@ +# Protobuf +###################### + +PROTODEPS := $(wildcard proto/*.proto) + # Typescript source ###################### @@ -105,12 +110,12 @@ JSDEPS := $(patsubst ts/src/%.ts, web/%.js, $(TSDEPS)) # Rust source ###################### -RSDEPS := $(wildcard rs/*/src/*.rs) +RSDEPS := $(shell find rs -type f | grep -v target) # Building ###################### -BUILDDEPS := .build/ui .build/js .build/rs +BUILDDEPS := .build/ui .build/js .build/rs .build/py-proto .build/ui: $(RUNREQS) $(shell find designer -type f) ./tools/build_ui.sh @@ -120,10 +125,14 @@ BUILDDEPS := .build/ui .build/js .build/rs (cd ts && npm run build) @touch $@ -.build/rs: .build/rust-deps $(RUNREQS) $(RSDEPS) +.build/rs: .build/rust-deps $(RUNREQS) $(RSDEPS) $(PROTODEPS) (cd rs/pybridge && maturin develop $(RUSTARGS)) @touch $@ +.build/py-proto: $(RUNREQS) $(PROTODEPS) + protoc -I proto --python_betterproto_out=anki/proto proto/bridge.proto + @touch $@ + .PHONY: build clean build: $(BUILDDEPS) @@ -147,6 +156,9 @@ run: build .PHONY: check check: rs-test rs-fmt rs-clippy py-mypy py-test py-fmt py-imports py-lint ts-fmt +.PHONY: fix +fix: fix-py-fmt fix-py-imports fix-rs-fmt fix-ts-fmt + # Checking python ###################### @@ -156,7 +168,7 @@ PYCHECKDEPS := $(BUILDDEPS) .build/py-check-reqs $(shell find anki aqt -name '*. mypy anki aqt @touch $@ -.build/pytest: $(PYCHECKDEPS) $(wildcard tests/*.py) +.build/py-test: $(PYCHECKDEPS) $(wildcard tests/*.py) ./tools/tests.sh @touch $@ diff --git a/anki/collection.py b/anki/collection.py index 8eb74b15e..9c36362a8 100644 --- a/anki/collection.py +++ b/anki/collection.py @@ -29,6 +29,7 @@ from anki.lang import _, ngettext from anki.media import MediaManager from anki.models import ModelManager from anki.notes import Note +from anki.rsbridge import RSBridge from anki.sched import Scheduler as V1Scheduler from anki.schedv2 import Scheduler as V2Scheduler from anki.sound import stripSounds @@ -84,8 +85,12 @@ class _Collection: ls: int conf: Dict[str, Any] _undo: List[Any] + rust: RSBridge - def __init__(self, db: DB, server: bool = False, log: bool = False) -> None: + def __init__( + self, db: DB, server: bool = False, log: bool = False, rust: RSBridge = None + ) -> None: + self.rust = rust self._debugLog = log self.db = db self.path = db._path diff --git a/anki/rsbridge.py b/anki/rsbridge.py index 9f0ac5e25..17132d528 100644 --- a/anki/rsbridge.py +++ b/anki/rsbridge.py @@ -1,10 +1,37 @@ import _ankirs # pytype: disable=import-error +import betterproto + +from anki.proto import proto as pb + + +class BridgeException(Exception): + def __str__(self) -> str: + err: pb.BridgeError = self.args[0] # pylint: disable=unsubscriptable-object + (kind, obj) = betterproto.which_one_of(err, "value") + if kind == "invalid_input": + return f"invalid input: {obj.info}" + else: + return f"unhandled error: {err} {obj}" class RSBridge: def __init__(self): self._bridge = _ankirs.Bridge() - assert self._bridge.cmd("") == "test" + + def _run_command(self, input: pb.BridgeInput) -> pb.BridgeOutput: + input_bytes = bytes(input) + output_bytes = self._bridge.command(input_bytes) + output = pb.BridgeOutput().parse(output_bytes) + (kind, obj) = betterproto.which_one_of(output, "value") + if kind == "error": + raise BridgeException(obj) + else: + return output + + def plus_one(self, num: int) -> int: + input = pb.BridgeInput(plus_one=pb.PlusOneIn(num=num)) + output = self._run_command(input) + return output.plus_one.num bridge = RSBridge() diff --git a/anki/storage.py b/anki/storage.py index 5855a1f95..b75a54827 100644 --- a/anki/storage.py +++ b/anki/storage.py @@ -12,6 +12,7 @@ from anki.collection import _Collection from anki.consts import * from anki.db import DB from anki.lang import _ +from anki.rsbridge import RSBridge from anki.stdmodels import ( addBasicModel, addBasicTypingModel, @@ -26,6 +27,8 @@ def Collection( path: str, lock: bool = True, server: bool = False, log: bool = False ) -> _Collection: "Open a new or existing collection. Path must be unicode." + bridge = RSBridge() + assert bridge.plus_one(5) == 6 assert path.endswith(".anki2") path = os.path.abspath(path) create = not os.path.exists(path) @@ -46,7 +49,7 @@ def Collection( db.execute("pragma journal_mode = wal") db.setAutocommit(False) # add db to col and do any remaining upgrades - col = _Collection(db, server, log) + col = _Collection(db, server, log, rust=bridge) if ver < SCHEMA_VERSION: _upgrade(col, ver) elif ver > SCHEMA_VERSION: diff --git a/proto/bridge.proto b/proto/bridge.proto new file mode 100644 index 000000000..a132e308b --- /dev/null +++ b/proto/bridge.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package proto; + +message Empty {} + +message BridgeInput { + oneof value { + PlusOneIn plus_one = 2; + } +} + +message BridgeOutput { + oneof value { + BridgeError error = 1; + PlusOneOut plus_one = 2; + } +} + +message BridgeError { + oneof value { + InvalidInputError invalid_input = 1; + } +} + +message InvalidInputError { + string info = 1; +} + +message PlusOneIn { + int32 num = 1; +} + +message PlusOneOut { + int32 num = 1; +} diff --git a/requirements.txt b/requirements.txt index 3dd8678ba..4788d0bb2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ jsonschema psutil; sys_platform == "win32" distro; sys_platform != "win32" and sys_platform != "darwin" typing +betterproto[compiler] diff --git a/rs/Cargo.lock b/rs/Cargo.lock index a8ec493ff..d63b0c82b 100644 --- a/rs/Cargo.lock +++ b/rs/Cargo.lock @@ -12,6 +12,12 @@ dependencies = [ [[package]] name = "ankirs" version = "0.1.0" +dependencies = [ + "bytes", + "failure", + "prost", + "prost-build", +] [[package]] name = "autocfg" @@ -19,14 +25,118 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2" +[[package]] +name = "backtrace" +version = "0.3.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924c76597f0d9ca25d762c25a4d369d51267536465dc5064bdf0eb073ed477ea" +dependencies = [ + "backtrace-sys", + "cfg-if", + "libc", + "rustc-demangle", +] + +[[package]] +name = "backtrace-sys" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6575f128516de27e3ce99689419835fce9643a9b215a14d2b5b685be018491" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "byteorder" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c3dd8985a7111efc5c80b44e23ecdd8c007de8ade3b96595387e812b957cf5" + +[[package]] +name = "bytes" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "206fdffcfa2df7cbe15601ef46c813fce0965eb3286db6b56c583b814b51c81c" +dependencies = [ + "byteorder", + "iovec", +] + +[[package]] +name = "c2-chacha" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214238caa1bf3a496ec3392968969cab8549f96ff30652c9e56885329315f6bb" +dependencies = [ + "ppv-lite86", +] + +[[package]] +name = "cc" +version = "1.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52a465a666ca3d838ebbf08b241383421412fe7ebb463527bba275526d89f76" + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "ctor" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd8ce37ad4184ab2ce004c33bf6379185d3b1c95801cab51026bd271bf68eedc" dependencies = [ - "quote", - "syn", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "either" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1f6b1ce1c140482ea30ddd3335fc0024ac7ee112895426e0a629a6c20adfe3" + +[[package]] +name = "failure" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8273f13c977665c5db7eb2b99ae520952fe5ac831ae4cd09d80c4c7042b5ed9" +dependencies = [ + "backtrace", + "failure_derive", +] + +[[package]] +name = "failure_derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bc225b78e0391e4b8683440bf2e63c2deeeb2ce5189eab46e2b68c6d3725d08" +dependencies = [ + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", + "synstructure", +] + +[[package]] +name = "fixedbitset" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86d4de0081402f5e88cdac65c8dcdcc73118c1a7a465e2a05f0da05843a8ea33" + +[[package]] +name = "getrandom" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7db7ca94ed4cd01190ceee0d8a8052f08a247aa1b469a7f68c6a3b71afcf407" +dependencies = [ + "cfg-if", + "libc", + "wasi", ] [[package]] @@ -35,9 +145,18 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a36606a68532b5640dc86bb1f33c64b45c4682aad4c50f3937b317ea387f3d6" dependencies = [ - "proc-macro2", - "quote", - "syn", + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "heck" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20564e78d53d2bb135c343b3f47714a56af2061f1c928fdb541dc7b9fdd94205" +dependencies = [ + "unicode-segmentation", ] [[package]] @@ -57,9 +176,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b714fc08d0961716390977cdff1536234415ac37b509e34e5a983def8340fb75" dependencies = [ "proc-macro-hack", - "proc-macro2", - "quote", - "syn", + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", "unindent", ] @@ -80,9 +199,27 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2869bf972e998977b1cb87e60df70341d48e48dca0823f534feb91ea44adaf9" dependencies = [ - "proc-macro2", - "quote", - "syn", + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "iovec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" +dependencies = [ + "libc", +] + +[[package]] +name = "itertools" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f56a2d0bc861f9165be4eb3442afd3c236d8a98afd426f65d92324ae1091a484" +dependencies = [ + "either", ] [[package]] @@ -103,12 +240,27 @@ version = "0.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d515b1f41455adea1313a4a2ac8a8a477634fbae63cc6100e3aebb207ce61558" +[[package]] +name = "log" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14b6052be84e6b71ab17edffc2eeabf5c2c3ae1fdb464aae35ac50c67a44e1f7" +dependencies = [ + "cfg-if", +] + [[package]] name = "memchr" version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88579771288728879b57485cc7d6b07d648c9f0141eb955f8ab7f9d45394468e" +[[package]] +name = "multimap" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eb04b9f127583ed176e163fb9ec6f3e793b87e21deedd5734a69386a18a0151" + [[package]] name = "num-traits" version = "0.2.10" @@ -135,20 +287,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4214c9e912ef61bf42b81ba9a47e8aad1b2ffaf739ab162bf96d1e011f54e6c5" dependencies = [ "proc-macro-hack", - "proc-macro2", - "quote", - "syn", + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", ] +[[package]] +name = "petgraph" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3659d1ee90221741f65dd128d9998311b0e40c5d3c23a62445938214abce4f" +dependencies = [ + "fixedbitset", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74490b50b9fbe561ac330df47c08f3f33073d2d00c150f719147d7c54522fa1b" + [[package]] name = "proc-macro-hack" version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecd45702f76d6d3c75a80564378ae228a85f0b59d2f3ed43c91b4a69eb2ebfc5" dependencies = [ - "proc-macro2", - "quote", - "syn", + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "proc-macro2" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf3d2011ab5c909338f7887f4fc896d35932e29146c12c8d01da6b22a80ba759" +dependencies = [ + "unicode-xid 0.1.0", ] [[package]] @@ -157,13 +333,66 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c9e470a8dc4aeae2dee2f335e8f533e2d4b347e1434e5671afc49b054592f27" dependencies = [ - "unicode-xid", + "unicode-xid 0.2.0", +] + +[[package]] +name = "prost" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d14b1c185652833d24aaad41c5832b0be5616a590227c1fbff57c616754b23" +dependencies = [ + "byteorder", + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb788126ea840817128183f8f603dce02cb7aea25c2a0b764359d8e20010702e" +dependencies = [ + "bytes", + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prost", + "prost-types", + "tempfile", + "which", +] + +[[package]] +name = "prost-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7dc378b94ac374644181a2247cebf59a6ec1c88b49ac77f3a94b86b79d0e11" +dependencies = [ + "failure", + "itertools", + "proc-macro2 0.4.30", + "quote 0.6.13", + "syn 0.15.44", +] + +[[package]] +name = "prost-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1de482a366941c8d56d19b650fac09ca08508f2a696119ee7513ad590c8bac6f" +dependencies = [ + "bytes", + "prost", ] [[package]] name = "pybridge" version = "0.1.0" dependencies = [ + "ankirs", "pyo3", ] @@ -194,9 +423,9 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f6e56fb3e97b344a8f87d036f94578399402c6b75949de6270cd07928f790b1" dependencies = [ - "proc-macro2", - "quote", - "syn", + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", ] [[package]] @@ -205,10 +434,19 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97452dcdf5941627ebc5c06664a07821fc7fc88d7515f02178193a8ebe316468" dependencies = [ - "proc-macro2", + "proc-macro2 1.0.6", "pyo3-derive-backend", - "quote", - "syn", + "quote 1.0.2", + "syn 1.0.11", +] + +[[package]] +name = "quote" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce23b6b870e8f94f81fb0a363d65d86675884b34a09043c81e5562f11c1f8e1" +dependencies = [ + "proc-macro2 0.4.30", ] [[package]] @@ -217,9 +455,56 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053a8c8bcc71fcce321828dc897a98ab9760bef03a4fc36693c231e5b3216cfe" dependencies = [ - "proc-macro2", + "proc-macro2 1.0.6", ] +[[package]] +name = "rand" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae1b169243eaf61759b8475a998f0a385e42042370f3a7dbaf35246eacc8412" +dependencies = [ + "getrandom", + "libc", + "rand_chacha", + "rand_core", + "rand_hc", +] + +[[package]] +name = "rand_chacha" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03a2a90da8c7523f554344f921aa97283eadf6ac484a6d2a7d0212fa7f8d6853" +dependencies = [ + "c2-chacha", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.1.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2439c63f3f6139d1b57529d16bc3b8bb855230c8efcc5d3a896c8bea7c3b1e84" + [[package]] name = "regex" version = "1.3.1" @@ -238,6 +523,21 @@ version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11a7e20d1cce64ef2fed88b66d347f88bd9babb82845b2b858f3edbf59a4f716" +[[package]] +name = "remove_dir_all" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a83fa3702a688b9359eccba92d153ac33fd2e8462f9e0e3fdf155239ea7792e" +dependencies = [ + "winapi", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c691c0e608126e00913e33f0ccf3727d5fc84573623b8d65b2df340b5201783" + [[package]] name = "ryu" version = "1.0.2" @@ -259,9 +559,9 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "128f9e303a5a29922045a830221b8f78ec74a5f544944f3d5984f8ec3895ef64" dependencies = [ - "proc-macro2", - "quote", - "syn", + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", ] [[package]] @@ -281,15 +581,52 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "syn" +version = "0.15.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ca4b3b69a77cbe1ffc9e198781b7acb0c7365a883670e8f1c1bc66fba79a5c5" +dependencies = [ + "proc-macro2 0.4.30", + "quote 0.6.13", + "unicode-xid 0.1.0", +] + [[package]] name = "syn" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff0acdb207ae2fe6d5976617f887eb1e35a2ba52c13c7234c790960cdad9238" dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", + "proc-macro2 1.0.6", + "quote 1.0.2", + "unicode-xid 0.2.0", +] + +[[package]] +name = "synstructure" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67656ea1dc1b41b1451851562ea232ec2e5a80242139f7e679ceccfb5d61f545" +dependencies = [ + "proc-macro2 1.0.6", + "quote 1.0.2", + "syn 1.0.11", + "unicode-xid 0.2.0", +] + +[[package]] +name = "tempfile" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6e24d9338a0a5be79593e2fa15a648add6138caa803e2d5bc782c371732ca9" +dependencies = [ + "cfg-if", + "libc", + "rand", + "redox_syscall", + "remove_dir_all", + "winapi", ] [[package]] @@ -301,6 +638,18 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "unicode-segmentation" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83e153d1053cbb5a118eeff7fd5be06ed99153f00dbcd8ae310c5fb2b22edc0" + +[[package]] +name = "unicode-xid" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc72304796d0818e357ead4e000d19c9c174ab23dc11093ac919054d20a6a7fc" + [[package]] name = "unicode-xid" version = "0.2.0" @@ -318,3 +667,41 @@ name = "version_check" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "078775d0255232fb988e6fccf26ddc9d1ac274299aaedcedce21c6f72cc533ce" + +[[package]] +name = "wasi" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c3ce4ce14bdc6fb6beaf9ec7928ca331de5df7e5ea278375642a2f478570d" + +[[package]] +name = "which" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b57acb10231b9493c8472b20cb57317d0679a49e0bdbee44b3b803a6473af164" +dependencies = [ + "failure", + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8093091eeb260906a183e6ae1abdba2ef5ef2257a21801128899c3fc699229c6" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/rs/ankirs/Cargo.toml b/rs/ankirs/Cargo.toml index 1981374cc..a9c34c962 100644 --- a/rs/ankirs/Cargo.toml +++ b/rs/ankirs/Cargo.toml @@ -3,3 +3,11 @@ name = "ankirs" version = "0.1.0" edition = "2018" authors = ["Ankitects Pty Ltd and contributors"] + +[dependencies] +failure = "0.1.6" +prost = "0.5.0" +bytes = "0.4" + +[build-dependencies] +prost-build = "0.5.0" diff --git a/rs/ankirs/build.rs b/rs/ankirs/build.rs new file mode 100644 index 000000000..c72a77327 --- /dev/null +++ b/rs/ankirs/build.rs @@ -0,0 +1,7 @@ +use prost_build; + +fn main() { + // avoid default OUT_DIR for now, for code completion + std::env::set_var("OUT_DIR", "src"); + prost_build::compile_protos(&["../../proto/bridge.proto"], &["../../proto/"]).unwrap(); +} diff --git a/rs/ankirs/src/bridge.rs b/rs/ankirs/src/bridge.rs new file mode 100644 index 000000000..df5a8c5a3 --- /dev/null +++ b/rs/ankirs/src/bridge.rs @@ -0,0 +1,84 @@ +use crate::err::{AnkiError, Result}; +use crate::proto as pt; +use crate::proto::bridge_input::Value; +use prost::Message; + +pub struct Bridge {} + +impl Default for Bridge { + fn default() -> Self { + Bridge {} + } +} + +/// Convert an Anki error to a protobuf error. +impl std::convert::From for pt::BridgeError { + fn from(err: AnkiError) -> Self { + use pt::bridge_error::Value as V; + let value = match err { + AnkiError::InvalidInput { info } => V::InvalidInput(pt::InvalidInputError { info }), + }; + + pt::BridgeError { value: Some(value) } + } +} + +// Convert an Anki error to a protobuf output. +impl std::convert::From for pt::bridge_output::Value { + fn from(err: AnkiError) -> Self { + pt::bridge_output::Value::Error(err.into()) + } +} + +impl Bridge { + pub fn new() -> Bridge { + Bridge::default() + } + + /// Decode a request, process it, and return the encoded result. + pub fn run_command_bytes(&mut self, req: &[u8]) -> Vec { + let mut buf = vec![]; + + let req = match pt::BridgeInput::decode(req) { + Ok(req) => req, + Err(_e) => { + // unable to decode + let err = AnkiError::invalid_input("couldn't decode bridge request"); + let output = pt::BridgeOutput { + value: Some(err.into()), + }; + output.encode(&mut buf).expect("encode failed"); + return buf; + } + }; + + let resp = self.run_command(req); + resp.encode(&mut buf).expect("encode failed"); + buf + } + + fn run_command(&self, input: pt::BridgeInput) -> pt::BridgeOutput { + let oval = if let Some(ival) = input.value { + match self.run_command_inner(ival) { + Ok(output) => output, + Err(err) => err.into(), + } + } else { + AnkiError::invalid_input("unrecognized bridge input value").into() + }; + + pt::BridgeOutput { value: Some(oval) } + } + + fn run_command_inner(&self, ival: pt::bridge_input::Value) -> Result { + use pt::bridge_output::Value as OValue; + Ok(match ival { + Value::PlusOne(input) => OValue::PlusOne(self.plus_one(input)?), + }) + } + + fn plus_one(&self, input: pt::PlusOneIn) -> Result { + let num = input.num + 1; + Ok(pt::PlusOneOut { num }) + } +} diff --git a/rs/ankirs/src/err.rs b/rs/ankirs/src/err.rs new file mode 100644 index 000000000..48b16d39e --- /dev/null +++ b/rs/ankirs/src/err.rs @@ -0,0 +1,16 @@ +pub use failure::{Error, Fail}; + +pub type Result = std::result::Result; + +#[derive(Debug, Fail)] +pub enum AnkiError { + #[fail(display = "invalid input: {}", info)] + InvalidInput { info: String }, +} + +// error helpers +impl AnkiError { + pub(crate) fn invalid_input>(s: S) -> AnkiError { + AnkiError::InvalidInput { info: s.into() } + } +} diff --git a/rs/ankirs/src/lib.rs b/rs/ankirs/src/lib.rs index 8b1378917..eb1b6bf97 100644 --- a/rs/ankirs/src/lib.rs +++ b/rs/ankirs/src/lib.rs @@ -1 +1,4 @@ +mod proto; +pub mod bridge; +pub mod err; diff --git a/rs/pybridge/Cargo.toml b/rs/pybridge/Cargo.toml index 0af13556b..6b87a5c18 100644 --- a/rs/pybridge/Cargo.toml +++ b/rs/pybridge/Cargo.toml @@ -4,6 +4,9 @@ version = "0.1.0" edition = "2018" authors = ["Ankitects Pty Ltd and contributors"] +[dependencies] +ankirs = { path = "../ankirs" } + [dependencies.pyo3] version = "0.8.0" features = ["extension-module"] diff --git a/rs/pybridge/src/lib.rs b/rs/pybridge/src/lib.rs index c56231b73..f48351bd0 100644 --- a/rs/pybridge/src/lib.rs +++ b/rs/pybridge/src/lib.rs @@ -1,19 +1,27 @@ -use pyo3::exceptions; +use ankirs::bridge::Bridge as RustBridge; use pyo3::prelude::*; +use pyo3::types::PyBytes; #[pyclass] -struct Bridge {} +struct Bridge { + bridge: RustBridge, +} #[pymethods] impl Bridge { #[new] fn init(obj: &PyRawObject) { - obj.init({ Bridge {} }); + obj.init({ + Bridge { + bridge: Default::default(), + } + }); } - fn cmd(&mut self, _request: String) -> PyResult { - Ok("test".to_string()) - .map_err(|e: std::io::Error| exceptions::Exception::py_err(format!("{:?}", e))) + fn command(&mut self, py: Python, input: &PyBytes) -> PyResult { + let out_bytes = self.bridge.run_command_bytes(input.as_bytes()); + let out_obj = PyBytes::new(py, &out_bytes); + Ok(out_obj.into()) } } diff --git a/rs/rustfmt.toml b/rs/rustfmt.toml new file mode 100644 index 000000000..c9dc0fc83 --- /dev/null +++ b/rs/rustfmt.toml @@ -0,0 +1 @@ +ignore = ["proto.rs"] From ecfce51dbdbff010943497880d7bd196532b411d Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 12:43:42 +1000 Subject: [PATCH 09/42] in single field case, both 'any' and 'all' are equivalent --- tests/test_models.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 9df8f7ef6..9c88d7e56 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -348,10 +348,15 @@ def test_req(): basic = mm.byName("Basic") assert 'req' in basic reqSize(basic) - assert basic['req'][0] == [0, 'all', [0]] + r = basic['req'][0] + assert r[0] == 0 + assert r[1] in ("any", "all") + assert r[2] == [0] opt = mm.byName("Basic (optional reversed card)") reqSize(opt) - assert opt['req'][0] == [0, 'all', [0]] + r = opt['req'][0] + assert r[1] in ("any", "all") + assert r[2] == [0] assert opt['req'][1] == [1, 'all', [1, 2]] #testing any opt['tmpls'][1]['qfmt'] = "{{Back}}{{Add Reverse}}" From 3ce4d5fd3d2aaa8cf222656942f42dcec7bc1913 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 14:05:15 +1000 Subject: [PATCH 10/42] compute template requirements in Rust on a 100 field template, what took ~75 seconds now takes ~3 seconds. --- anki/models.py | 11 ++ anki/rsbridge.py | 36 +++- anki/types.py | 7 +- proto/bridge.proto | 32 ++++ rs/Cargo.lock | 78 ++++++++- rs/ankirs/Cargo.toml | 1 + rs/ankirs/src/bridge.rs | 52 ++++++ rs/ankirs/src/err.rs | 7 + rs/ankirs/src/lib.rs | 1 + rs/ankirs/src/template.rs | 353 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 574 insertions(+), 4 deletions(-) create mode 100644 rs/ankirs/src/template.rs diff --git a/anki/models.py b/anki/models.py index 89f091d54..ed00cdcae 100644 --- a/anki/models.py +++ b/anki/models.py @@ -556,6 +556,9 @@ select id from notes where mid = ?)""" ########################################################################## def _updateRequired(self, m: NoteType) -> None: + self._updateRequiredNew(m) + + def _updateRequiredLegacy(self, m: NoteType) -> None: if m["type"] == MODEL_CLOZE: # nothing to do return @@ -566,6 +569,14 @@ select id from notes where mid = ?)""" req.append([t["ord"], ret[0], ret[1]]) m["req"] = req + def _updateRequiredNew(self, m: NoteType) -> None: + fronts = [t["qfmt"] for t in m["tmpls"]] + field_map = {} + for (idx, fld) in enumerate(m["flds"]): + field_map[fld["name"]] = idx + reqs = self.col.rust.template_requirements(fronts, field_map) + m["req"] = [list(l) for l in reqs] + def _reqForTemplate( self, m: NoteType, flds: List[str], t: Template ) -> Tuple[Union[str, List[int]], ...]: diff --git a/anki/rsbridge.py b/anki/rsbridge.py index 17132d528..e7e4574c7 100644 --- a/anki/rsbridge.py +++ b/anki/rsbridge.py @@ -1,8 +1,12 @@ +from typing import Dict, List + import _ankirs # pytype: disable=import-error import betterproto from anki.proto import proto as pb +from .types import AllTemplateReqs + class BridgeException(Exception): def __str__(self) -> str: @@ -10,10 +14,30 @@ class BridgeException(Exception): (kind, obj) = betterproto.which_one_of(err, "value") if kind == "invalid_input": return f"invalid input: {obj.info}" + elif kind == "template_parse": + return f"template parse: {obj.info}" else: return f"unhandled error: {err} {obj}" +def proto_template_reqs_to_legacy( + reqs: List[pb.TemplateRequirement], +) -> AllTemplateReqs: + legacy_reqs = [] + for (idx, req) in enumerate(reqs): + (kind, val) = betterproto.which_one_of(req, "value") + # fixme: sorting is for the unit tests - should check if any + # code depends on the order + if kind == "any": + legacy_reqs.append((idx, "any", sorted(req.any.ords))) + elif kind == "all": + legacy_reqs.append((idx, "all", sorted(req.all.ords))) + else: + l: List[int] = [] + legacy_reqs.append((idx, "none", l)) + return legacy_reqs + + class RSBridge: def __init__(self): self._bridge = _ankirs.Bridge() @@ -33,5 +57,13 @@ class RSBridge: output = self._run_command(input) return output.plus_one.num - -bridge = RSBridge() + def template_requirements( + self, template_fronts: List[str], field_map: Dict[str, int] + ) -> AllTemplateReqs: + input = pb.BridgeInput( + template_requirements=pb.TemplateRequirementsIn( + template_front=template_fronts, field_names_to_ordinals=field_map + ) + ) + output = self._run_command(input).template_requirements + return proto_template_reqs_to_legacy(output.requirements) diff --git a/anki/types.py b/anki/types.py index cdf742296..19395eaa2 100644 --- a/anki/types.py +++ b/anki/types.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Tuple, Union +from typing import Any, Dict, List, Tuple, Union # Model attributes are stored in a dict keyed by strings. This type alias # provides more descriptive function signatures than just 'Dict[str, Any]' @@ -31,3 +31,8 @@ QAData = Tuple[ # Corresponds to 'cardFlags' column. TODO: document int, ] + +TemplateRequirementType = str # Union["all", "any", "none"] +# template ordinal, type, list of field ordinals +TemplateRequiredFieldOrds = Tuple[int, TemplateRequirementType, List[int]] +AllTemplateReqs = List[TemplateRequiredFieldOrds] diff --git a/proto/bridge.proto b/proto/bridge.proto index a132e308b..3fed1978d 100644 --- a/proto/bridge.proto +++ b/proto/bridge.proto @@ -7,6 +7,7 @@ message Empty {} message BridgeInput { oneof value { PlusOneIn plus_one = 2; + TemplateRequirementsIn template_requirements = 3; } } @@ -14,12 +15,14 @@ message BridgeOutput { oneof value { BridgeError error = 1; PlusOneOut plus_one = 2; + TemplateRequirementsOut template_requirements = 3; } } message BridgeError { oneof value { InvalidInputError invalid_input = 1; + TemplateParseError template_parse = 2; } } @@ -34,3 +37,32 @@ message PlusOneIn { message PlusOneOut { int32 num = 1; } + +message TemplateParseError { + string info = 1; +} + +message TemplateRequirementsIn { + repeated string template_front = 1; + map field_names_to_ordinals = 2; +} + +message TemplateRequirementsOut { + repeated TemplateRequirement requirements = 1; +} + +message TemplateRequirement { + oneof value { + TemplateRequirementAll all = 1; + TemplateRequirementAny any = 2; + Empty none = 3; + } +} + +message TemplateRequirementAll { + repeated uint32 ords = 1; +} + +message TemplateRequirementAny { + repeated uint32 ords = 1; +} diff --git a/rs/Cargo.lock b/rs/Cargo.lock index d63b0c82b..d72b38d5f 100644 --- a/rs/Cargo.lock +++ b/rs/Cargo.lock @@ -15,10 +15,20 @@ version = "0.1.0" dependencies = [ "bytes", "failure", + "nom", "prost", "prost-build", ] +[[package]] +name = "arrayvec" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9fd44efafa8690358b7408d253adf110036b88f55672a933f01d616ad9b1b9" +dependencies = [ + "nodrop", +] + [[package]] name = "autocfg" version = "0.1.7" @@ -234,6 +244,19 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lexical-core" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304bccb228c4b020f3a4835d247df0a02a7c4686098d4167762cfbbe4c5cb14" +dependencies = [ + "arrayvec", + "cfg-if", + "rustc_version", + "ryu", + "static_assertions", +] + [[package]] name = "libc" version = "0.2.66" @@ -261,6 +284,23 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04b9f127583ed176e163fb9ec6f3e793b87e21deedd5734a69386a18a0151" +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "nom" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c618b63422da4401283884e6668d39f819a106ef51f5f59b81add00075da35ca" +dependencies = [ + "lexical-core", + "memchr", + "version_check 0.1.5", +] + [[package]] name = "num-traits" version = "0.2.10" @@ -414,7 +454,7 @@ dependencies = [ "serde_json", "spin", "unindent", - "version_check", + "version_check 0.9.1", ] [[package]] @@ -538,12 +578,36 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c691c0e608126e00913e33f0ccf3727d5fc84573623b8d65b2df340b5201783" +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "semver", +] + [[package]] name = "ryu" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa8506c1de11c9c4e4c38863ccbe02a305c8188e85a05a784c9e11e1c3910c8" +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "semver-parser", +] + +[[package]] +name = "semver-parser" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" + [[package]] name = "serde" version = "1.0.104" @@ -581,6 +645,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" +[[package]] +name = "static_assertions" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f3eb36b47e512f8f1c9e3d10c2c1965bc992bd9cdb024fa581e2194501c83d3" + [[package]] name = "syn" version = "0.15.44" @@ -662,6 +732,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63f18aa3b0e35fed5a0048f029558b1518095ffe2a0a31fb87c93dece93a4993" +[[package]] +name = "version_check" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914b1a6776c4c929a602fafd8bc742e06365d4bcbe48c30f9cca5824f70dc9dd" + [[package]] name = "version_check" version = "0.9.1" diff --git a/rs/ankirs/Cargo.toml b/rs/ankirs/Cargo.toml index a9c34c962..eb11872f3 100644 --- a/rs/ankirs/Cargo.toml +++ b/rs/ankirs/Cargo.toml @@ -5,6 +5,7 @@ edition = "2018" authors = ["Ankitects Pty Ltd and contributors"] [dependencies] +nom = "5.0.1" failure = "0.1.6" prost = "0.5.0" bytes = "0.4" diff --git a/rs/ankirs/src/bridge.rs b/rs/ankirs/src/bridge.rs index df5a8c5a3..549cad884 100644 --- a/rs/ankirs/src/bridge.rs +++ b/rs/ankirs/src/bridge.rs @@ -1,7 +1,9 @@ use crate::err::{AnkiError, Result}; use crate::proto as pt; use crate::proto::bridge_input::Value; +use crate::template::{FieldMap, FieldRequirements, ParsedTemplate}; use prost::Message; +use std::collections::HashSet; pub struct Bridge {} @@ -17,6 +19,9 @@ impl std::convert::From for pt::BridgeError { use pt::bridge_error::Value as V; let value = match err { AnkiError::InvalidInput { info } => V::InvalidInput(pt::InvalidInputError { info }), + AnkiError::TemplateParseError { info } => { + V::TemplateParse(pt::TemplateParseError { info }) + } }; pt::BridgeError { value: Some(value) } @@ -73,6 +78,9 @@ impl Bridge { fn run_command_inner(&self, ival: pt::bridge_input::Value) -> Result { use pt::bridge_output::Value as OValue; Ok(match ival { + Value::TemplateRequirements(input) => { + OValue::TemplateRequirements(self.template_requirements(input)?) + } Value::PlusOne(input) => OValue::PlusOne(self.plus_one(input)?), }) } @@ -81,4 +89,48 @@ impl Bridge { let num = input.num + 1; Ok(pt::PlusOneOut { num }) } + + fn template_requirements( + &self, + input: pt::TemplateRequirementsIn, + ) -> Result { + let map: FieldMap = input + .field_names_to_ordinals + .iter() + .map(|(name, ord)| (name.as_str(), *ord as u16)) + .collect(); + // map each provided template into a requirements list + use crate::proto::template_requirement::Value; + let all_reqs = input + .template_front + .into_iter() + .map(|template| { + if let Ok(tmpl) = ParsedTemplate::from_text(&template) { + // convert the rust structure into a protobuf one + let val = match tmpl.requirements(&map) { + FieldRequirements::Any(ords) => Value::Any(pt::TemplateRequirementAny { + ords: ords_hash_to_set(ords), + }), + FieldRequirements::All(ords) => Value::All(pt::TemplateRequirementAll { + ords: ords_hash_to_set(ords), + }), + FieldRequirements::None => Value::None(pt::Empty {}), + }; + Ok(pt::TemplateRequirement { value: Some(val) }) + } else { + // template parsing failures make card unsatisfiable + Ok(pt::TemplateRequirement { + value: Some(Value::None(pt::Empty {})), + }) + } + }) + .collect::>>()?; + Ok(pt::TemplateRequirementsOut { + requirements: all_reqs, + }) + } +} + +fn ords_hash_to_set(ords: HashSet) -> Vec { + ords.iter().map(|ord| *ord as u32).collect() } diff --git a/rs/ankirs/src/err.rs b/rs/ankirs/src/err.rs index 48b16d39e..acf3a2062 100644 --- a/rs/ankirs/src/err.rs +++ b/rs/ankirs/src/err.rs @@ -6,10 +6,17 @@ pub type Result = std::result::Result; pub enum AnkiError { #[fail(display = "invalid input: {}", info)] InvalidInput { info: String }, + + #[fail(display = "invalid card template: {}", info)] + TemplateParseError { info: String }, } // error helpers impl AnkiError { + pub(crate) fn parse>(s: S) -> AnkiError { + AnkiError::TemplateParseError { info: s.into() } + } + pub(crate) fn invalid_input>(s: S) -> AnkiError { AnkiError::InvalidInput { info: s.into() } } diff --git a/rs/ankirs/src/lib.rs b/rs/ankirs/src/lib.rs index eb1b6bf97..c05971249 100644 --- a/rs/ankirs/src/lib.rs +++ b/rs/ankirs/src/lib.rs @@ -2,3 +2,4 @@ mod proto; pub mod bridge; pub mod err; +pub mod template; diff --git a/rs/ankirs/src/template.rs b/rs/ankirs/src/template.rs new file mode 100644 index 000000000..89f7589e3 --- /dev/null +++ b/rs/ankirs/src/template.rs @@ -0,0 +1,353 @@ +use crate::err::{AnkiError, Result}; +use nom; +use nom::branch::alt; +use nom::bytes::complete::tag; +use nom::error::ErrorKind; +use nom::sequence::delimited; +use std::collections::{HashMap, HashSet}; + +pub type FieldMap<'a> = HashMap<&'a str, u16>; + +// Lexing +//---------------------------------------- + +#[derive(Debug)] +pub enum Token<'a> { + Text(&'a str), + Replacement(&'a str), + OpenConditional(&'a str), + OpenNegated(&'a str), + CloseConditional(&'a str), +} + +/// a span of text, terminated by {{, }} or end of string +pub(crate) fn text_until_handlebars(s: &str) -> nom::IResult<&str, &str> { + let end = s.len(); + + let limited_end = end + .min(s.find("{{").unwrap_or(end)) + .min(s.find("}}").unwrap_or(end)); + let (output, input) = s.split_at(limited_end); + if output.is_empty() { + Err(nom::Err::Error((input, ErrorKind::TakeUntil))) + } else { + Ok((input, output)) + } +} + +/// text outside handlebars +fn text_token(s: &str) -> nom::IResult<&str, Token> { + text_until_handlebars(s).map(|(input, output)| (input, Token::Text(output))) +} + +/// text wrapped in handlebars +fn handle_token(s: &str) -> nom::IResult<&str, Token> { + delimited(tag("{{"), text_until_handlebars, tag("}}"))(s) + .map(|(input, output)| (input, classify_handle(output))) +} + +/// classify handle based on leading character +fn classify_handle(s: &str) -> Token { + let start = s.trim(); + if start.len() < 2 { + return Token::Replacement(start); + } + if start.starts_with('#') { + Token::OpenConditional(&start[1..].trim_start()) + } else if start.starts_with('/') { + Token::CloseConditional(&start[1..].trim_start()) + } else if start.starts_with('^') { + Token::OpenNegated(&start[1..].trim_start()) + } else { + Token::Replacement(start) + } +} + +fn next_token(input: &str) -> nom::IResult<&str, Token> { + alt((handle_token, text_token))(input) +} + +fn tokens(template: &str) -> impl Iterator> { + let mut data = template; + + std::iter::from_fn(move || { + if data.is_empty() { + return None; + } + match next_token(data) { + Ok((i, o)) => { + data = i; + Some(Ok(o)) + } + Err(e) => Some(Err(AnkiError::parse(format!("{:?}", e)))), + } + }) +} + +// Parsing +//---------------------------------------- + +#[derive(Debug, PartialEq)] +enum ParsedNode<'a> { + Text(&'a str), + Replacement { + key: &'a str, + filters: Vec<&'a str>, + }, + Conditional { + key: &'a str, + children: Vec>, + }, + NegatedConditional { + key: &'a str, + children: Vec>, + }, +} + +#[derive(Debug)] +pub struct ParsedTemplate<'a>(Vec>); + +impl ParsedTemplate<'_> { + pub fn from_text(template: &str) -> Result { + let mut iter = tokens(template); + Ok(Self(parse_inner(&mut iter, None)?)) + } +} + +fn parse_inner<'a, I: Iterator>>>( + iter: &mut I, + open_tag: Option<&'a str>, +) -> Result>> { + let mut nodes = vec![]; + + while let Some(token) = iter.next() { + use Token::*; + nodes.push(match token? { + Text(t) => ParsedNode::Text(t), + Replacement(t) => { + let mut it = t.rsplit(':'); + ParsedNode::Replacement { + key: it.next().unwrap(), + filters: it.collect(), + } + } + OpenConditional(t) => ParsedNode::Conditional { + key: t, + children: parse_inner(iter, Some(t))?, + }, + OpenNegated(t) => ParsedNode::NegatedConditional { + key: t, + children: parse_inner(iter, Some(t))?, + }, + CloseConditional(t) => { + if let Some(open) = open_tag { + if open == t { + // matching closing tag, move back to parent + return Ok(nodes); + } + } + return Err(AnkiError::parse(format!( + "unbalanced closing tag: {:?} / {}", + open_tag, t + ))); + } + }); + } + + if let Some(open) = open_tag { + Err(AnkiError::parse(format!("unclosed conditional {}", open))) + } else { + Ok(nodes) + } +} + +// Checking if template is empty +//---------------------------------------- + +impl ParsedTemplate<'_> { + /// true if provided fields are sufficient to render the template + pub fn renders_with_fields(&self, nonempty_fields: &HashSet<&str>) -> bool { + !template_is_empty(nonempty_fields, &self.0) + } +} + +fn template_is_empty<'a>(nonempty_fields: &HashSet<&str>, nodes: &[ParsedNode<'a>]) -> bool { + use ParsedNode::*; + for node in nodes { + match node { + // ignore normal text + Text(_) => (), + Replacement { key, .. } => { + if nonempty_fields.contains(*key) { + // a single replacement is enough + return false; + } + } + Conditional { key, children } => { + if !nonempty_fields.contains(*key) { + continue; + } + if !template_is_empty(nonempty_fields, children) { + return false; + } + } + NegatedConditional { .. } => { + // negated conditionals ignored when determining card generation + continue; + } + } + } + + true +} + +// Compatibility with old Anki versions +//---------------------------------------- + +#[derive(Debug, Clone, PartialEq)] +pub enum FieldRequirements { + Any(HashSet), + All(HashSet), + None, +} + +impl ParsedTemplate<'_> { + /// Return fields required by template. + /// + /// This is not able to represent negated expressions or combinations of + /// Any and All, and is provided only for the sake of backwards + /// compatibility. + pub fn requirements(&self, field_map: &FieldMap) -> FieldRequirements { + let mut nonempty: HashSet<_> = Default::default(); + let mut ords = HashSet::new(); + for (name, ord) in field_map { + nonempty.clear(); + nonempty.insert(*name); + if self.renders_with_fields(&nonempty) { + ords.insert(*ord); + } + } + if !ords.is_empty() { + return FieldRequirements::Any(ords); + } + + nonempty.extend(field_map.keys()); + ords.extend(field_map.values().copied()); + for (name, ord) in field_map { + // can we remove this field and still render? + nonempty.remove(name); + if self.renders_with_fields(&nonempty) { + ords.remove(ord); + } + nonempty.insert(*name); + } + if !ords.is_empty() && self.renders_with_fields(&nonempty) { + FieldRequirements::All(ords) + } else { + FieldRequirements::None + } + } +} + +// Tests +//--------------------------------------- + +#[cfg(test)] +mod test { + use super::{FieldMap, ParsedNode::*, ParsedTemplate as PT}; + use crate::template::FieldRequirements; + use std::collections::HashSet; + use std::iter::FromIterator; + + #[test] + fn test_parsing() { + let tmpl = PT::from_text("foo {{bar}} {{#baz}} quux {{/baz}}").unwrap(); + assert_eq!( + tmpl.0, + vec![ + Text("foo "), + Replacement { + key: "bar", + filters: vec![] + }, + Text(" "), + Conditional { + key: "baz", + children: vec![Text(" quux ")] + } + ] + ); + + let tmpl = PT::from_text("{{^baz}}{{/baz}}").unwrap(); + assert_eq!( + tmpl.0, + vec![NegatedConditional { + key: "baz", + children: vec![] + }] + ); + + PT::from_text("{{#mis}}{{/matched}}").unwrap_err(); + PT::from_text("{{/matched}}").unwrap_err(); + PT::from_text("{{#mis}}").unwrap_err(); + + // whitespace + assert_eq!( + PT::from_text("{{ tag }}").unwrap().0, + vec![Replacement { + key: "tag", + filters: vec![] + }] + ); + } + + #[test] + fn test_nonempty() { + let fields = HashSet::from_iter(vec!["1", "3"].into_iter()); + let mut tmpl = PT::from_text("{{2}}{{1}}").unwrap(); + assert_eq!(tmpl.renders_with_fields(&fields), true); + tmpl = PT::from_text("{{2}}{{type:cloze:1}}").unwrap(); + assert_eq!(tmpl.renders_with_fields(&fields), true); + tmpl = PT::from_text("{{2}}{{4}}").unwrap(); + assert_eq!(tmpl.renders_with_fields(&fields), false); + tmpl = PT::from_text("{{#3}}{{^2}}{{1}}{{/2}}{{/3}}").unwrap(); + assert_eq!(tmpl.renders_with_fields(&fields), false); + } + + #[test] + fn test_requirements() { + let field_map: FieldMap = vec!["a", "b"] + .iter() + .enumerate() + .map(|(a, b)| (*b, a as u16)) + .collect(); + + let mut tmpl = PT::from_text("{{a}}{{b}}").unwrap(); + assert_eq!( + tmpl.requirements(&field_map), + FieldRequirements::Any(HashSet::from_iter(vec![0, 1].into_iter())) + ); + + tmpl = PT::from_text("{{#a}}{{b}}{{/a}}").unwrap(); + assert_eq!( + tmpl.requirements(&field_map), + FieldRequirements::All(HashSet::from_iter(vec![0, 1].into_iter())) + ); + + tmpl = PT::from_text("{{c}}").unwrap(); + assert_eq!(tmpl.requirements(&field_map), FieldRequirements::None); + + tmpl = PT::from_text("{{^a}}{{b}}{{/a}}").unwrap(); + assert_eq!(tmpl.requirements(&field_map), FieldRequirements::None); + + tmpl = PT::from_text("{{#a}}{{#b}}{{a}}{{/b}}{{/a}}").unwrap(); + assert_eq!( + tmpl.requirements(&field_map), + FieldRequirements::All(HashSet::from_iter(vec![0, 1].into_iter())) + ); + + // fixme: handling of type in answer card reqs doesn't match desktop, + // which only requires first field + // + } +} From b54c1273728e73bbec02f3300552151ce9ce72d1 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 14:48:49 +1000 Subject: [PATCH 11/42] switch to Google's (C++) Protobuf implementation Brings the 100 field test down from 3 secs to 0.15 secs. "betterproto" indeed! --- .gitignore | 2 +- Makefile | 2 +- anki/rsbridge.py | 22 +++++++++++----------- requirements.txt | 3 ++- 4 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index d82845757..37d4319f9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ .pytype __pycache__ anki/buildhash.py -anki/proto +anki/bridge_pb2.py aqt/forms locale rs/ankirs/src/proto.rs diff --git a/Makefile b/Makefile index ed9edf687..79d4aab0d 100644 --- a/Makefile +++ b/Makefile @@ -130,7 +130,7 @@ BUILDDEPS := .build/ui .build/js .build/rs .build/py-proto @touch $@ .build/py-proto: $(RUNREQS) $(PROTODEPS) - protoc -I proto --python_betterproto_out=anki/proto proto/bridge.proto + protoc --proto_path=proto --python_out=anki proto/bridge.proto @touch $@ .PHONY: build clean diff --git a/anki/rsbridge.py b/anki/rsbridge.py index e7e4574c7..9e294c4f8 100644 --- a/anki/rsbridge.py +++ b/anki/rsbridge.py @@ -1,9 +1,8 @@ from typing import Dict, List import _ankirs # pytype: disable=import-error -import betterproto -from anki.proto import proto as pb +import anki.bridge_pb2 as pb from .types import AllTemplateReqs @@ -11,13 +10,13 @@ from .types import AllTemplateReqs class BridgeException(Exception): def __str__(self) -> str: err: pb.BridgeError = self.args[0] # pylint: disable=unsubscriptable-object - (kind, obj) = betterproto.which_one_of(err, "value") + kind = err.WhichOneof("value") if kind == "invalid_input": - return f"invalid input: {obj.info}" + return f"invalid input: {err.invalid_input.info}" elif kind == "template_parse": - return f"template parse: {obj.info}" + return f"template parse: {err.template_parse.info}" else: - return f"unhandled error: {err} {obj}" + return f"unhandled error: {err}" def proto_template_reqs_to_legacy( @@ -25,7 +24,7 @@ def proto_template_reqs_to_legacy( ) -> AllTemplateReqs: legacy_reqs = [] for (idx, req) in enumerate(reqs): - (kind, val) = betterproto.which_one_of(req, "value") + kind = req.WhichOneof("value") # fixme: sorting is for the unit tests - should check if any # code depends on the order if kind == "any": @@ -43,12 +42,13 @@ class RSBridge: self._bridge = _ankirs.Bridge() def _run_command(self, input: pb.BridgeInput) -> pb.BridgeOutput: - input_bytes = bytes(input) + input_bytes = input.SerializeToString() output_bytes = self._bridge.command(input_bytes) - output = pb.BridgeOutput().parse(output_bytes) - (kind, obj) = betterproto.which_one_of(output, "value") + output = pb.BridgeOutput() + output.ParseFromString(output_bytes) + kind = output.WhichOneof("value") if kind == "error": - raise BridgeException(obj) + raise BridgeException(output.error) else: return output diff --git a/requirements.txt b/requirements.txt index 4788d0bb2..b045feb3a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,5 @@ jsonschema psutil; sys_platform == "win32" distro; sys_platform != "win32" and sys_platform != "darwin" typing -betterproto[compiler] +protobuf + From 280b01049382532fc5b35e36014ac3c973b9f165 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 15:00:05 +1000 Subject: [PATCH 12/42] generate type stubs for Protobuf; disable pylint on rsbridge --- .gitignore | 2 +- Makefile | 2 +- anki/rsbridge.py | 5 ++++- requirements.check | 1 + 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 37d4319f9..281c532d6 100644 --- a/.gitignore +++ b/.gitignore @@ -10,7 +10,7 @@ .pytype __pycache__ anki/buildhash.py -anki/bridge_pb2.py +anki/bridge_pb2.* aqt/forms locale rs/ankirs/src/proto.rs diff --git a/Makefile b/Makefile index 79d4aab0d..9bdbd6bfa 100644 --- a/Makefile +++ b/Makefile @@ -130,7 +130,7 @@ BUILDDEPS := .build/ui .build/js .build/rs .build/py-proto @touch $@ .build/py-proto: $(RUNREQS) $(PROTODEPS) - protoc --proto_path=proto --python_out=anki proto/bridge.proto + protoc --proto_path=proto --python_out=anki --mypy_out=anki proto/bridge.proto @touch $@ .PHONY: build clean diff --git a/anki/rsbridge.py b/anki/rsbridge.py index 9e294c4f8..58dc753ec 100644 --- a/anki/rsbridge.py +++ b/anki/rsbridge.py @@ -1,3 +1,5 @@ +# pylint: skip-file + from typing import Dict, List import _ankirs # pytype: disable=import-error @@ -66,4 +68,5 @@ class RSBridge: ) ) output = self._run_command(input).template_requirements - return proto_template_reqs_to_legacy(output.requirements) + reqs: List[pb.TemplateRequirement] = output.requirements # type: ignore + return proto_template_reqs_to_legacy(reqs) diff --git a/requirements.check b/requirements.check index aee07e685..ebe707bba 100644 --- a/requirements.check +++ b/requirements.check @@ -6,3 +6,4 @@ git+https://github.com/dae/isort#egg=isort # fixme: when pylint supports isort 5.0, switch to pypy git+https://github.com/dae/pylint#egg=pylint black +mypy_protobuf \ No newline at end of file From 4168c01141dc217623b0f04461b005b7656028f4 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 18:38:04 +1000 Subject: [PATCH 13/42] update dev docs --- README.development | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/README.development b/README.development index 4c71b82fd..0c774de59 100644 --- a/README.development +++ b/README.development @@ -20,10 +20,8 @@ To start, make sure you have the following installed: - npm - your platform's C compiler, eg gcc, Xcode or Visual Studio 2017. - GNU make - -Once the above are installed, install rustup from https://rustup.rs/. You can -save some time by choosing a custom installation, and selecting a toolkit of -'none' and 'minimal' tools. + - protoc (https://github.com/protocolbuffers/protobuf/releases) + - rustup (https://rustup.rs/) Next, build a Python virtual environment and activate it: From 6a8ea9e65b63999c7200cb0924fbad1b4d1324f8 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 19:12:59 +1000 Subject: [PATCH 14/42] if db lock fails, explicitly close database on Windows at least, the database file otherwise remains locked after an exception is raised on a read only file --- anki/storage.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/anki/storage.py b/anki/storage.py index b75a54827..842bb6e2d 100644 --- a/anki/storage.py +++ b/anki/storage.py @@ -63,7 +63,11 @@ def Collection( addBasicModel(col) col.save() if lock: - col.lock() + try: + col.lock() + except: + col.db.close() + raise return col From 387a5896a4c26c3ea902cb25fe243981b07108c7 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 19:14:48 +1000 Subject: [PATCH 15/42] fix unit test on Windows --- tests/test_collection.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_collection.py b/tests/test_collection.py index 7aed8d127..9abc91c92 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -1,6 +1,8 @@ # coding: utf-8 import os, tempfile + +from anki.utils import isWin from tests.shared import assertException, getEmptyCol from anki.stdmodels import addBasicModel, models @@ -30,8 +32,12 @@ def test_create_open(): deck.close() # non-writeable dir + if isWin: + dir = "c:\root.anki2" + else: + dir = "/attachroot.anki2" assertException(Exception, - lambda: aopen("/attachroot.anki2")) + lambda: aopen(dir)) # reuse tmp file from before, test non-writeable file os.chmod(newPath, 0) assertException(Exception, From 1c9a3f086bc6e1b4fe78c4d11bfc585cef9ca919 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 19:15:21 +1000 Subject: [PATCH 16/42] remove unnecessary global from tests --- tests/test_collection.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/test_collection.py b/tests/test_collection.py index 9abc91c92..37304cd47 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -8,11 +8,7 @@ from anki.stdmodels import addBasicModel, models from anki import Collection as aopen -newPath = None -newMod = None - def test_create_open(): - global newPath, newMod (fd, path) = tempfile.mkstemp(suffix=".anki2", prefix="test_attachNew") try: os.close(fd) From 2280a0e58ae835011dd71aef22400f5f2a71ab8b Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 19:16:09 +1000 Subject: [PATCH 17/42] switch to nose2; nose looks to be on the way out --- requirements.check | 2 +- tools/tests.sh | 11 ++--------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/requirements.check b/requirements.check index ebe707bba..9ce1fe6a5 100644 --- a/requirements.check +++ b/requirements.check @@ -1,4 +1,4 @@ -nose +nose2 mock mypy==0.750 # fixme: when isort 5.0 is released, switch to pypy diff --git a/tools/tests.sh b/tools/tests.sh index b6fe8f12c..40c8f8b27 100755 --- a/tools/tests.sh +++ b/tools/tests.sh @@ -10,11 +10,7 @@ set -e BIN="$(cd "`dirname "$0"`"; pwd)" export PYTHONPATH=${BIN}/..:${PYTHONPATH} -# favour nosetests3 if available -nose=nosetests -if which nosetests3 >/dev/null 2>&1; then - nose=nosetests3 -fi +nose="python -m nose2 --plugin=nose2.plugins.mp -N 16" dir=. @@ -24,7 +20,4 @@ else lim="tests.test_$1" fi -if [ x$coverage != x ]; then - args="--with-coverage" -fi -(cd $dir && $nose -s --processes=16 --process-timeout=300 $lim $args --cover-package=anki) +(cd $dir && $nose $lim $args) From 252f74b19c0862fe3d612b21fc77d2b8f3c55917 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 19:36:04 +1000 Subject: [PATCH 18/42] hack around race condition triggered in tests on win32 --- tests/test_models.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_models.py b/tests/test_models.py index 9c88d7e56..daa5bfdbb 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,8 +1,9 @@ # coding: utf-8 +import time from tests.shared import getEmptyCol from anki.consts import MODEL_CLOZE -from anki.utils import stripHTML, joinFields +from anki.utils import stripHTML, joinFields, isWin import anki.template def test_modelDelete(): @@ -267,6 +268,9 @@ def test_modelChange(): assert f.cards()[0].id == c1.id # delete first card map = {0: None, 1: 1} + if isWin: + # The low precision timer on Windows reveals a race condition + time.sleep(0.05) deck.models.change(basic, [f.id], basic, None, map) f.load() c0.load() From 9b0cbb6c51b88bf637b4270d2d4b27e736371e54 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 19:49:31 +1000 Subject: [PATCH 19/42] comment out updatereqs speed test --- tests/test_models.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index daa5bfdbb..78581aa67 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -371,17 +371,17 @@ def test_req(): mm.save(opt, templates=True) assert opt['req'][1] == [1, 'none', []] -def test_updatereqs_performance(): - import time - d = getEmptyCol() - mm = d.models - m = mm.byName("Basic") - for i in range(100): - fld = mm.newField(f"field{i}") - mm.addField(m, fld) - tmpl = mm.newTemplate(f"template{i}") - tmpl['qfmt'] = "{{field%s}}" % i - mm.addTemplate(m, tmpl) - t = time.time() - mm.save(m, templates=True) - print("took", (time.time()-t)*100) \ No newline at end of file +# def test_updatereqs_performance(): +# import time +# d = getEmptyCol() +# mm = d.models +# m = mm.byName("Basic") +# for i in range(100): +# fld = mm.newField(f"field{i}") +# mm.addField(m, fld) +# tmpl = mm.newTemplate(f"template{i}") +# tmpl['qfmt'] = "{{field%s}}" % i +# mm.addTemplate(m, tmpl) +# t = time.time() +# mm.save(m, templates=True) +# print("took", (time.time()-t)*100) From d45ea2b273ccc59b7b8b68e107020e4b83185fc3 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 19:49:40 +1000 Subject: [PATCH 20/42] exclude generated proto files from isort --- .isort.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.isort.cfg b/.isort.cfg index d8c44da35..8de3ca32c 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,5 +1,5 @@ [settings] -skip=aqt/forms +skip=aqt/forms,anki/bridge_pb2.py,bridge_pb2.pyi multi_line_output=3 include_trailing_comma=True force_grid_wrap=0 From c4dc40d0dbd7223923fc04d4b236e90c4ea85fbc Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 19:49:55 +1000 Subject: [PATCH 21/42] ignore dot files and generated files when determining rust deps --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9bdbd6bfa..d68fdb5e2 100644 --- a/Makefile +++ b/Makefile @@ -110,7 +110,7 @@ JSDEPS := $(patsubst ts/src/%.ts, web/%.js, $(TSDEPS)) # Rust source ###################### -RSDEPS := $(shell find rs -type f | grep -v target) +RSDEPS := $(shell find rs -type f | egrep -v 'target|/\.|proto.rs') # Building ###################### From 06e917f80b2e45a9bca348314ca25b795abe8b6a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 20:15:47 +1000 Subject: [PATCH 22/42] factor in user scale factor when calculating height fixes top bar line not appearing, and possible fix for https://anki.tenderapp.com/discussions/beta-testing/1685-black-bars --- aqt/webview.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/aqt/webview.py b/aqt/webview.py index e398baadf..a3c7b4dbc 100644 --- a/aqt/webview.py +++ b/aqt/webview.py @@ -416,11 +416,15 @@ body {{ zoom: {}; background: {}; {} }} self.evalWithCallback("$(document.body).height()", self._onHeight) def _onHeight(self, qvar): + from aqt import mw if qvar is None: - from aqt import mw mw.progress.timer(1000, mw.reset, False) return - height = math.ceil(qvar * self.zoomFactor()) + scaleFactor = self.zoomFactor() + if scaleFactor == 1: + scaleFactor = mw.pm.uiScale() + + height = math.ceil(qvar * scaleFactor) self.setFixedHeight(height) From 9767b6b8a5dba2c1681b6e1b185074982d6c7946 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 20:23:36 +1000 Subject: [PATCH 23/42] fix failure to start on new install - lang init needs to be deferred --- aqt/__init__.py | 6 ++++-- aqt/profiles.py | 5 ++--- aqt/webview.py | 1 + 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/aqt/__init__.py b/aqt/__init__.py index 49ac71d49..b9b4f56f5 100644 --- a/aqt/__init__.py +++ b/aqt/__init__.py @@ -392,12 +392,14 @@ environment points to a valid, writable folder.""", ) return + if pmLoadResult.firstTime: + pm.setDefaultLang() + if pmLoadResult.loadError: QMessageBox.warning( None, "Preferences Corrupt", - """\ - Anki's prefs21.db file was corrupt and has been recreated. If you were using multiple \ + """Anki's prefs21.db file was corrupt and has been recreated. If you were using multiple \ profiles, please add them back using the same names to recover your cards.""", ) diff --git a/aqt/profiles.py b/aqt/profiles.py index 64980feb3..4e2e956b9 100644 --- a/aqt/profiles.py +++ b/aqt/profiles.py @@ -385,7 +385,6 @@ create table if not exists profiles "insert or replace into profiles values ('_global', ?)", self._pickle(metaConf), ) - self._setDefaultLang() return result def _ensureProfile(self): @@ -409,7 +408,7 @@ please see: ###################################################################### # On first run, allow the user to choose the default language - def _setDefaultLang(self): + def setDefaultLang(self): # create dialog class NoCloseDiag(QDialog): def reject(self): @@ -452,7 +451,7 @@ please see: None, "Anki", en % name, QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if r != QMessageBox.Yes: - return self._setDefaultLang() + return self.setDefaultLang() self.setLang(code) def setLang(self, code): diff --git a/aqt/webview.py b/aqt/webview.py index a3c7b4dbc..19211efcb 100644 --- a/aqt/webview.py +++ b/aqt/webview.py @@ -417,6 +417,7 @@ body {{ zoom: {}; background: {}; {} }} def _onHeight(self, qvar): from aqt import mw + if qvar is None: mw.progress.timer(1000, mw.reset, False) From c078cdddfdf01c4ed5c83b3c1b24d07cd8791547 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 20:33:39 +1000 Subject: [PATCH 24/42] if base folder can't be written, defer displaying message until qt is ready --- aqt/__init__.py | 29 ++++++++++++++++++++++------- aqt/profiles.py | 17 +---------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/aqt/__init__.py b/aqt/__init__.py index b9b4f56f5..f55769f60 100644 --- a/aqt/__init__.py +++ b/aqt/__init__.py @@ -332,11 +332,19 @@ def _run(argv=None, exec=True): opts, args = parseArgs(argv) # profile manager - pm = ProfileManager(opts.base) - pmLoadResult = pm.setupMeta() + pm = None + try: + pm = ProfileManager(opts.base) + pmLoadResult = pm.setupMeta() + except: + # will handle below + pass - # gl workarounds - setupGL(pm) + if pm: + # gl workarounds + setupGL(pm) + # apply user-provided scale factor + os.environ["QT_SCALE_FACTOR"] = str(pm.uiScale()) # opt in to full hidpi support? if not os.environ.get("ANKI_NOHIGHDPI"): @@ -348,9 +356,6 @@ def _run(argv=None, exec=True): if os.environ.get("ANKI_SOFTWAREOPENGL"): QCoreApplication.setAttribute(Qt.AA_UseSoftwareOpenGL) - # apply user-provided scale factor - os.environ["QT_SCALE_FACTOR"] = str(pm.uiScale()) - # create the app QCoreApplication.setApplicationName("Anki") QGuiApplication.setDesktopFileName("anki.desktop") @@ -359,6 +364,16 @@ def _run(argv=None, exec=True): # we've signaled the primary instance, so we should close return + if not pm: + QMessageBox.critical( + None, + "Error", + """\ +Anki could not create its data folder. Please see the File Locations \ +section of the manual, and ensure that location is not read-only.""", + ) + return + # disable icons on mac; this must be done before window created if isMac: app.setAttribute(Qt.AA_DontShowIconsInMenus) diff --git a/aqt/profiles.py b/aqt/profiles.py index 4e2e956b9..1b3f7e619 100644 --- a/aqt/profiles.py +++ b/aqt/profiles.py @@ -97,22 +97,7 @@ class ProfileManager: ###################################################################### def ensureBaseExists(self): - try: - self._ensureExists(self.base) - except: - # can't translate, as lang not initialized, and qt may not be - print("unable to create base folder") - QMessageBox.critical( - None, - "Error", - """\ -Anki could not create the folder %s. Please ensure that location is not \ -read-only and you have permission to write to it. If you cannot fix this \ -issue, please see the documentation for information on running Anki from \ -a flash drive.""" - % self.base, - ) - raise + self._ensureExists(self.base) # Folder migration ###################################################################### From 5a8d088531c817bad820bdce3964c87cf38065b7 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 24 Dec 2019 21:42:40 +1000 Subject: [PATCH 25/42] convert nose decorators and helpers to nose2 --- tests/test_addons.py | 4 ++-- tests/test_exporting.py | 15 +++++++++------ tests/test_find.py | 12 ++++++------ 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/test_addons.py b/tests/test_addons.py index f42bc2c5d..954ea8594 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -1,8 +1,8 @@ import os.path -from nose.tools import assert_equals from mock import MagicMock from tempfile import TemporaryDirectory from zipfile import ZipFile +from nose2.tools.such import helper from aqt.addons import AddonManager @@ -80,4 +80,4 @@ def assertReadManifest(contents, expectedManifest, nameInZip="manifest.json"): adm = AddonManager(MagicMock()) with ZipFile(zfn, "r") as zfile: - assert_equals(adm.readManifestFile(zfile), expectedManifest) + helper.assertEquals(adm.readManifestFile(zfile), expectedManifest) diff --git a/tests/test_exporting.py b/tests/test_exporting.py index 005bdd011..f1a14d800 100644 --- a/tests/test_exporting.py +++ b/tests/test_exporting.py @@ -1,6 +1,9 @@ # coding: utf-8 -import nose, os, tempfile +import os, tempfile + +from nose2.tools.decorators import with_setup + from anki import Collection as aopen from anki.exporting import * from anki.importing import Anki2Importer @@ -24,7 +27,7 @@ def setup1(): ########################################################################## -@nose.with_setup(setup1) +@with_setup(setup1) def test_export_anki(): # create a new deck with its own conf to test conf copying did = deck.decks.id("test") @@ -65,7 +68,7 @@ def test_export_anki(): d2 = aopen(newname) assert d2.cardCount() == 1 -@nose.with_setup(setup1) +@with_setup(setup1) def test_export_ankipkg(): # add a test file to the media folder with open(os.path.join(deck.media.dir(), "今日.mp3"), "w") as f: @@ -80,7 +83,7 @@ def test_export_ankipkg(): os.unlink(newname) e.exportInto(newname) -@nose.with_setup(setup1) +@with_setup(setup1) def test_export_anki_due(): deck = getEmptyCol() f = deck.newNote() @@ -112,7 +115,7 @@ def test_export_anki_due(): deck2.sched.reset() assert c.due - deck2.sched.today == 1 -# @nose.with_setup(setup1) +# @with_setup(setup1) # def test_export_textcard(): # e = TextCardExporter(deck) # f = unicode(tempfile.mkstemp(prefix="ankitest")[1]) @@ -121,7 +124,7 @@ def test_export_anki_due(): # e.includeTags = True # e.exportInto(f) -@nose.with_setup(setup1) +@with_setup(setup1) def test_export_textnote(): e = TextNoteExporter(deck) fd, f = tempfile.mkstemp(prefix="ankitest") diff --git a/tests/test_find.py b/tests/test_find.py index 9faa1b7bb..cc7e91ede 100644 --- a/tests/test_find.py +++ b/tests/test_find.py @@ -1,5 +1,5 @@ # coding: utf-8 -from nose.tools import assert_raises +from nose2.tools.such import helper from anki.find import Finder from tests.shared import getEmptyCol @@ -100,7 +100,7 @@ def test_findCards(): assert len(deck.findCards("nid:%d"%f.id)) == 2 assert len(deck.findCards("nid:%d,%d" % (f1id, f2id))) == 2 # templates - with assert_raises(Exception): + with helper.assertRaises(Exception): deck.findCards("card:foo") assert len(deck.findCards("'card:card 1'")) == 4 assert len(deck.findCards("card:reverse")) == 1 @@ -136,7 +136,7 @@ def test_findCards(): assert len(deck.findCards("-deck:foo")) == 5 assert len(deck.findCards("deck:def*")) == 5 assert len(deck.findCards("deck:*EFAULT")) == 5 - with assert_raises(Exception): + with helper.assertRaises(Exception): deck.findCards("deck:*cefault") # full search f = deck.newNote() @@ -153,7 +153,7 @@ def test_findCards(): #assert len(deck.findCards("helloworld", full=True)) == 2 #assert len(deck.findCards("back:helloworld", full=True)) == 2 # searching for an invalid special tag should not error - with assert_raises(Exception): + with helper.assertRaises(Exception): len(deck.findCards("is:invalid")) # should be able to limit to parent deck, no children id = deck.db.scalar("select id from cards limit 1") @@ -225,9 +225,9 @@ def test_findCards(): assert len(deck.findCards("added:1")) == deck.cardCount() - 1 assert len(deck.findCards("added:2")) == deck.cardCount() # flag - with assert_raises(Exception): + with helper.assertRaises(Exception): deck.findCards("flag:01") - with assert_raises(Exception): + with helper.assertRaises(Exception): deck.findCards("flag:12") def test_findReplace(): From 3e1b474dca8cba0714ae55d3924b87f0c5141d22 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 25 Dec 2019 08:59:33 +1000 Subject: [PATCH 26/42] bridge->backend --- .gitignore | 4 +-- .isort.cfg | 2 +- Makefile | 4 +-- anki/__init__.py | 3 -- anki/{rsbridge.py => backend.py} | 22 ++++++------ anki/collection.py | 8 ++--- anki/models.py | 2 +- anki/storage.py | 10 +++--- proto/{bridge.proto => backend.proto} | 10 +++--- rs/Cargo.lock | 2 +- rs/Cargo.toml | 2 +- rs/ankirs/build.rs | 2 +- rs/ankirs/src/{bridge.rs => backend.rs} | 47 +++++++++++++------------ rs/ankirs/src/lib.rs | 4 +-- rs/{pybridge => pymod}/Cargo.toml | 2 +- rs/{pybridge => pymod}/src/lib.rs | 16 ++++----- rs/rustfmt.toml | 2 +- 17 files changed, 72 insertions(+), 70 deletions(-) rename anki/{rsbridge.py => backend.py} (78%) rename proto/{bridge.proto => backend.proto} (89%) rename rs/ankirs/src/{bridge.rs => backend.rs} (77%) rename rs/{pybridge => pymod}/Cargo.toml (93%) rename rs/{pybridge => pymod}/src/lib.rs (60%) diff --git a/.gitignore b/.gitignore index 281c532d6..9c3b0c91f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,10 +10,10 @@ .pytype __pycache__ anki/buildhash.py -anki/bridge_pb2.* +anki/backend_pb2.* aqt/forms locale -rs/ankirs/src/proto.rs +rs/ankirs/src/backend_proto.rs rs/target tools/runanki.system ts/node_modules diff --git a/.isort.cfg b/.isort.cfg index 8de3ca32c..6992ada53 100644 --- a/.isort.cfg +++ b/.isort.cfg @@ -1,5 +1,5 @@ [settings] -skip=aqt/forms,anki/bridge_pb2.py,bridge_pb2.pyi +skip=aqt/forms,anki/backend_pb2.py,backend_pb2.pyi multi_line_output=3 include_trailing_comma=True force_grid_wrap=0 diff --git a/Makefile b/Makefile index d68fdb5e2..9cae8398a 100644 --- a/Makefile +++ b/Makefile @@ -126,11 +126,11 @@ BUILDDEPS := .build/ui .build/js .build/rs .build/py-proto @touch $@ .build/rs: .build/rust-deps $(RUNREQS) $(RSDEPS) $(PROTODEPS) - (cd rs/pybridge && maturin develop $(RUSTARGS)) + (cd rs/pymod && maturin develop $(RUSTARGS)) @touch $@ .build/py-proto: $(RUNREQS) $(PROTODEPS) - protoc --proto_path=proto --python_out=anki --mypy_out=anki proto/bridge.proto + protoc --proto_path=proto --python_out=anki --mypy_out=anki proto/backend.proto @touch $@ .PHONY: build clean diff --git a/anki/__init__.py b/anki/__init__.py index 19468b6df..baea42e12 100644 --- a/anki/__init__.py +++ b/anki/__init__.py @@ -6,9 +6,6 @@ import sys from anki.storage import Collection -# temporary -from . import rsbridge - if sys.version_info[0] < 3 or sys.version_info[1] < 5: raise Exception("Anki requires Python 3.5+") diff --git a/anki/rsbridge.py b/anki/backend.py similarity index 78% rename from anki/rsbridge.py rename to anki/backend.py index 58dc753ec..1ce8d66d4 100644 --- a/anki/rsbridge.py +++ b/anki/backend.py @@ -4,14 +4,14 @@ from typing import Dict, List import _ankirs # pytype: disable=import-error -import anki.bridge_pb2 as pb +import anki.backend_pb2 as pb from .types import AllTemplateReqs -class BridgeException(Exception): +class BackendException(Exception): def __str__(self) -> str: - err: pb.BridgeError = self.args[0] # pylint: disable=unsubscriptable-object + err: pb.BackendError = self.args[0] # pylint: disable=unsubscriptable-object kind = err.WhichOneof("value") if kind == "invalid_input": return f"invalid input: {err.invalid_input.info}" @@ -39,30 +39,30 @@ def proto_template_reqs_to_legacy( return legacy_reqs -class RSBridge: +class Backend: def __init__(self): - self._bridge = _ankirs.Bridge() + self._backend = _ankirs.Backend() - def _run_command(self, input: pb.BridgeInput) -> pb.BridgeOutput: + def _run_command(self, input: pb.BackendInput) -> pb.BackendOutput: input_bytes = input.SerializeToString() - output_bytes = self._bridge.command(input_bytes) - output = pb.BridgeOutput() + output_bytes = self._backend.command(input_bytes) + output = pb.BackendOutput() output.ParseFromString(output_bytes) kind = output.WhichOneof("value") if kind == "error": - raise BridgeException(output.error) + raise BackendException(output.error) else: return output def plus_one(self, num: int) -> int: - input = pb.BridgeInput(plus_one=pb.PlusOneIn(num=num)) + input = pb.BackendInput(plus_one=pb.PlusOneIn(num=num)) output = self._run_command(input) return output.plus_one.num def template_requirements( self, template_fronts: List[str], field_map: Dict[str, int] ) -> AllTemplateReqs: - input = pb.BridgeInput( + input = pb.BackendInput( template_requirements=pb.TemplateRequirementsIn( template_front=template_fronts, field_names_to_ordinals=field_map ) diff --git a/anki/collection.py b/anki/collection.py index 9c36362a8..775b1457b 100644 --- a/anki/collection.py +++ b/anki/collection.py @@ -19,6 +19,7 @@ import anki.find import anki.latex # sets up hook import anki.notes import anki.template +from anki.backend import Backend from anki.cards import Card from anki.consts import * from anki.db import DB @@ -29,7 +30,6 @@ from anki.lang import _, ngettext from anki.media import MediaManager from anki.models import ModelManager from anki.notes import Note -from anki.rsbridge import RSBridge from anki.sched import Scheduler as V1Scheduler from anki.schedv2 import Scheduler as V2Scheduler from anki.sound import stripSounds @@ -85,12 +85,12 @@ class _Collection: ls: int conf: Dict[str, Any] _undo: List[Any] - rust: RSBridge + backend: Backend def __init__( - self, db: DB, server: bool = False, log: bool = False, rust: RSBridge = None + self, db: DB, backend: Backend, server: bool = False, log: bool = False ) -> None: - self.rust = rust + self.backend = backend self._debugLog = log self.db = db self.path = db._path diff --git a/anki/models.py b/anki/models.py index ed00cdcae..04d21c383 100644 --- a/anki/models.py +++ b/anki/models.py @@ -574,7 +574,7 @@ select id from notes where mid = ?)""" field_map = {} for (idx, fld) in enumerate(m["flds"]): field_map[fld["name"]] = idx - reqs = self.col.rust.template_requirements(fronts, field_map) + reqs = self.col.backend.template_requirements(fronts, field_map) m["req"] = [list(l) for l in reqs] def _reqForTemplate( diff --git a/anki/storage.py b/anki/storage.py index 842bb6e2d..259661897 100644 --- a/anki/storage.py +++ b/anki/storage.py @@ -8,11 +8,11 @@ import os import re from typing import Any, Dict, Tuple +from anki.backend import Backend from anki.collection import _Collection from anki.consts import * from anki.db import DB from anki.lang import _ -from anki.rsbridge import RSBridge from anki.stdmodels import ( addBasicModel, addBasicTypingModel, @@ -27,8 +27,10 @@ def Collection( path: str, lock: bool = True, server: bool = False, log: bool = False ) -> _Collection: "Open a new or existing collection. Path must be unicode." - bridge = RSBridge() - assert bridge.plus_one(5) == 6 + backend = Backend() + # fixme: this call is temporarily here to ensure the brige is working + # on all platforms, and should be removed in a future beta + assert backend.plus_one(5) == 6 assert path.endswith(".anki2") path = os.path.abspath(path) create = not os.path.exists(path) @@ -49,7 +51,7 @@ def Collection( db.execute("pragma journal_mode = wal") db.setAutocommit(False) # add db to col and do any remaining upgrades - col = _Collection(db, server, log, rust=bridge) + col = _Collection(db, backend=backend, server=server, log=log) if ver < SCHEMA_VERSION: _upgrade(col, ver) elif ver > SCHEMA_VERSION: diff --git a/proto/bridge.proto b/proto/backend.proto similarity index 89% rename from proto/bridge.proto rename to proto/backend.proto index 3fed1978d..f8a0a61a3 100644 --- a/proto/bridge.proto +++ b/proto/backend.proto @@ -1,25 +1,25 @@ syntax = "proto3"; -package proto; +package backend_proto; message Empty {} -message BridgeInput { +message BackendInput { oneof value { PlusOneIn plus_one = 2; TemplateRequirementsIn template_requirements = 3; } } -message BridgeOutput { +message BackendOutput { oneof value { - BridgeError error = 1; + BackendError error = 1; PlusOneOut plus_one = 2; TemplateRequirementsOut template_requirements = 3; } } -message BridgeError { +message BackendError { oneof value { InvalidInputError invalid_input = 1; TemplateParseError template_parse = 2; diff --git a/rs/Cargo.lock b/rs/Cargo.lock index d72b38d5f..f1f3c14db 100644 --- a/rs/Cargo.lock +++ b/rs/Cargo.lock @@ -429,7 +429,7 @@ dependencies = [ ] [[package]] -name = "pybridge" +name = "pymod" version = "0.1.0" dependencies = [ "ankirs", diff --git a/rs/Cargo.toml b/rs/Cargo.toml index d261ed4c3..84ba7cfc3 100644 --- a/rs/Cargo.toml +++ b/rs/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["ankirs", "pybridge"] +members = ["ankirs", "pymod"] [profile.release] lto = true diff --git a/rs/ankirs/build.rs b/rs/ankirs/build.rs index c72a77327..88ef4f590 100644 --- a/rs/ankirs/build.rs +++ b/rs/ankirs/build.rs @@ -3,5 +3,5 @@ use prost_build; fn main() { // avoid default OUT_DIR for now, for code completion std::env::set_var("OUT_DIR", "src"); - prost_build::compile_protos(&["../../proto/bridge.proto"], &["../../proto/"]).unwrap(); + prost_build::compile_protos(&["../../proto/backend.proto"], &["../../proto/"]).unwrap(); } diff --git a/rs/ankirs/src/bridge.rs b/rs/ankirs/src/backend.rs similarity index 77% rename from rs/ankirs/src/bridge.rs rename to rs/ankirs/src/backend.rs index 549cad884..3a730126b 100644 --- a/rs/ankirs/src/bridge.rs +++ b/rs/ankirs/src/backend.rs @@ -1,22 +1,22 @@ +use crate::backend_proto as pt; +use crate::backend_proto::backend_input::Value; use crate::err::{AnkiError, Result}; -use crate::proto as pt; -use crate::proto::bridge_input::Value; use crate::template::{FieldMap, FieldRequirements, ParsedTemplate}; use prost::Message; use std::collections::HashSet; -pub struct Bridge {} +pub struct Backend {} -impl Default for Bridge { +impl Default for Backend { fn default() -> Self { - Bridge {} + Backend {} } } /// Convert an Anki error to a protobuf error. -impl std::convert::From for pt::BridgeError { +impl std::convert::From for pt::BackendError { fn from(err: AnkiError) -> Self { - use pt::bridge_error::Value as V; + use pt::backend_error::Value as V; let value = match err { AnkiError::InvalidInput { info } => V::InvalidInput(pt::InvalidInputError { info }), AnkiError::TemplateParseError { info } => { @@ -24,32 +24,32 @@ impl std::convert::From for pt::BridgeError { } }; - pt::BridgeError { value: Some(value) } + pt::BackendError { value: Some(value) } } } // Convert an Anki error to a protobuf output. -impl std::convert::From for pt::bridge_output::Value { +impl std::convert::From for pt::backend_output::Value { fn from(err: AnkiError) -> Self { - pt::bridge_output::Value::Error(err.into()) + pt::backend_output::Value::Error(err.into()) } } -impl Bridge { - pub fn new() -> Bridge { - Bridge::default() +impl Backend { + pub fn new() -> Backend { + Backend::default() } /// Decode a request, process it, and return the encoded result. pub fn run_command_bytes(&mut self, req: &[u8]) -> Vec { let mut buf = vec![]; - let req = match pt::BridgeInput::decode(req) { + let req = match pt::BackendInput::decode(req) { Ok(req) => req, Err(_e) => { // unable to decode - let err = AnkiError::invalid_input("couldn't decode bridge request"); - let output = pt::BridgeOutput { + let err = AnkiError::invalid_input("couldn't decode backend request"); + let output = pt::BackendOutput { value: Some(err.into()), }; output.encode(&mut buf).expect("encode failed"); @@ -62,21 +62,24 @@ impl Bridge { buf } - fn run_command(&self, input: pt::BridgeInput) -> pt::BridgeOutput { + fn run_command(&self, input: pt::BackendInput) -> pt::BackendOutput { let oval = if let Some(ival) = input.value { match self.run_command_inner(ival) { Ok(output) => output, Err(err) => err.into(), } } else { - AnkiError::invalid_input("unrecognized bridge input value").into() + AnkiError::invalid_input("unrecognized backend input value").into() }; - pt::BridgeOutput { value: Some(oval) } + pt::BackendOutput { value: Some(oval) } } - fn run_command_inner(&self, ival: pt::bridge_input::Value) -> Result { - use pt::bridge_output::Value as OValue; + fn run_command_inner( + &self, + ival: pt::backend_input::Value, + ) -> Result { + use pt::backend_output::Value as OValue; Ok(match ival { Value::TemplateRequirements(input) => { OValue::TemplateRequirements(self.template_requirements(input)?) @@ -100,7 +103,7 @@ impl Bridge { .map(|(name, ord)| (name.as_str(), *ord as u16)) .collect(); // map each provided template into a requirements list - use crate::proto::template_requirement::Value; + use crate::backend_proto::template_requirement::Value; let all_reqs = input .template_front .into_iter() diff --git a/rs/ankirs/src/lib.rs b/rs/ankirs/src/lib.rs index c05971249..c42efa231 100644 --- a/rs/ankirs/src/lib.rs +++ b/rs/ankirs/src/lib.rs @@ -1,5 +1,5 @@ -mod proto; +mod backend_proto; -pub mod bridge; +pub mod backend; pub mod err; pub mod template; diff --git a/rs/pybridge/Cargo.toml b/rs/pymod/Cargo.toml similarity index 93% rename from rs/pybridge/Cargo.toml rename to rs/pymod/Cargo.toml index 6b87a5c18..b09c3399a 100644 --- a/rs/pybridge/Cargo.toml +++ b/rs/pymod/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "pybridge" +name = "pymod" version = "0.1.0" edition = "2018" authors = ["Ankitects Pty Ltd and contributors"] diff --git a/rs/pybridge/src/lib.rs b/rs/pymod/src/lib.rs similarity index 60% rename from rs/pybridge/src/lib.rs rename to rs/pymod/src/lib.rs index f48351bd0..dbb18c339 100644 --- a/rs/pybridge/src/lib.rs +++ b/rs/pymod/src/lib.rs @@ -1,25 +1,25 @@ -use ankirs::bridge::Bridge as RustBridge; +use ankirs::backend::Backend as RustBackend; use pyo3::prelude::*; use pyo3::types::PyBytes; #[pyclass] -struct Bridge { - bridge: RustBridge, +struct Backend { + backend: RustBackend, } #[pymethods] -impl Bridge { +impl Backend { #[new] fn init(obj: &PyRawObject) { obj.init({ - Bridge { - bridge: Default::default(), + Backend { + backend: Default::default(), } }); } fn command(&mut self, py: Python, input: &PyBytes) -> PyResult { - let out_bytes = self.bridge.run_command_bytes(input.as_bytes()); + let out_bytes = self.backend.run_command_bytes(input.as_bytes()); let out_obj = PyBytes::new(py, &out_bytes); Ok(out_obj.into()) } @@ -27,7 +27,7 @@ impl Bridge { #[pymodule] fn _ankirs(_py: Python, m: &PyModule) -> PyResult<()> { - m.add_class::()?; + m.add_class::()?; Ok(()) } diff --git a/rs/rustfmt.toml b/rs/rustfmt.toml index c9dc0fc83..bd0ab67a8 100644 --- a/rs/rustfmt.toml +++ b/rs/rustfmt.toml @@ -1 +1 @@ -ignore = ["proto.rs"] +ignore = ["backend_proto.rs"] From 518cc44267034031d3b0681d789b464c1139f388 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 25 Dec 2019 09:19:57 +1000 Subject: [PATCH 27/42] add a few type hints --- anki/decks.py | 3 ++- anki/notes.py | 6 +++++- anki/tags.py | 3 ++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/anki/decks.py b/anki/decks.py index 041d98d49..97916b4b2 100644 --- a/anki/decks.py +++ b/anki/decks.py @@ -8,6 +8,7 @@ import operator import unicodedata from typing import Any, Dict, List, Optional, Set, Tuple, Union +import anki # pylint: disable=unused-import from anki.consts import * from anki.errors import DeckRenameError from anki.hooks import runHook @@ -98,7 +99,7 @@ class DeckManager: # Registry save/load ############################################################# - def __init__(self, col) -> None: + def __init__(self, col: "anki.storage._Collection") -> None: self.col = col self.decks = {} self.dconf = {} diff --git a/anki/notes.py b/anki/notes.py index 26055a2aa..5b26249dc 100644 --- a/anki/notes.py +++ b/anki/notes.py @@ -4,6 +4,7 @@ from typing import Any, List, Optional, Tuple +import anki # pylint: disable=unused-import from anki.utils import ( fieldChecksum, guid64, @@ -19,7 +20,10 @@ class Note: tags: List[str] def __init__( - self, col, model: Optional[Any] = None, id: Optional[int] = None + self, + col: "anki.storage._Collection", + model: Optional[Any] = None, + id: Optional[int] = None, ) -> None: assert not (model and id) self.col = col diff --git a/anki/tags.py b/anki/tags.py index d0730b381..33c528131 100644 --- a/anki/tags.py +++ b/anki/tags.py @@ -14,6 +14,7 @@ import json import re from typing import Callable, Dict, List, Tuple +import anki # pylint: disable=unused-import from anki.hooks import runHook from anki.utils import ids2str, intTime @@ -23,7 +24,7 @@ class TagManager: # Registry save/load ############################################################# - def __init__(self, col) -> None: + def __init__(self, col: "anki.storage._Collection") -> None: self.col = col self.tags: Dict[str, int] = {} From e3eaff862b10743a364b060b0ddb078c44ef1704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Pokorn=C3=BD=20=28Rai=29?= Date: Wed, 25 Dec 2019 01:25:57 +0100 Subject: [PATCH 28/42] Adding myself to contributor list --- aqt/about.py | 1 + 1 file changed, 1 insertion(+) diff --git a/aqt/about.py b/aqt/about.py index 29b29c0f7..41e180725 100644 --- a/aqt/about.py +++ b/aqt/about.py @@ -148,6 +148,7 @@ system. It's free and open source." "David Bailey", "Arman High", "Arthur Milchior", + "Rai (Michael Pokorny)", ) ) From b681c7aad3b1a1139e1a88a78fb7dde7cb4ce45e Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 25 Dec 2019 10:26:16 +1000 Subject: [PATCH 29/42] add protoc to github actions --- .github/workflows/checks.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 9ad6008d6..5746f3d1c 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -20,6 +20,8 @@ jobs: uses: actions/setup-node@v1 with: node-version: 12 + - name: Set up Protoc + uses: Arduino/actions/setup-protoc@master - name: Run checks run: | sudo apt install portaudio19-dev From c6d287c3ee1f924a16670c193d9f31ceacb29492 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 25 Dec 2019 10:35:31 +1000 Subject: [PATCH 30/42] mypy is required to build the protobuf code --- requirements.check | 2 -- requirements.txt | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.check b/requirements.check index 9ce1fe6a5..291acc797 100644 --- a/requirements.check +++ b/requirements.check @@ -1,9 +1,7 @@ nose2 mock -mypy==0.750 # fixme: when isort 5.0 is released, switch to pypy git+https://github.com/dae/isort#egg=isort # fixme: when pylint supports isort 5.0, switch to pypy git+https://github.com/dae/pylint#egg=pylint black -mypy_protobuf \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index b045feb3a..a4618cd26 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,6 @@ psutil; sys_platform == "win32" distro; sys_platform != "win32" and sys_platform != "darwin" typing protobuf +mypy==0.750 +mypy_protobuf From 7f7138031435a1a558ada86d6344df120b72ea71 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 25 Dec 2019 10:51:18 +1000 Subject: [PATCH 31/42] fix for isort failing to install in CI --- .github/workflows/checks.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 5746f3d1c..f3c3a52c8 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -28,4 +28,5 @@ jobs: python${{ matrix.python-version }} -m venv ~/pyenv . ~/pyenv/bin/activate pip install -r requirements.qt + pip install --upgrade setuptools pip make check RUSTARGS="" From 37d5ea8469f776551fa18966a4a1e64faf43c279 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 25 Dec 2019 11:33:23 +1000 Subject: [PATCH 32/42] tweak dev docs --- README.development | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.development b/README.development index 0c774de59..91e41b022 100644 --- a/README.development +++ b/README.development @@ -20,8 +20,9 @@ To start, make sure you have the following installed: - npm - your platform's C compiler, eg gcc, Xcode or Visual Studio 2017. - GNU make - - protoc (https://github.com/protocolbuffers/protobuf/releases) + - protoc v3 (https://github.com/protocolbuffers/protobuf/releases) - rustup (https://rustup.rs/) + - pip 19+ Next, build a Python virtual environment and activate it: @@ -58,11 +59,11 @@ Mac users You can use homebrew to install some dependencies: -$ brew install python mpv lame portaudio +$ brew install python mpv lame portaudio protobuf npm rustup-init Windows users -------------- -The build scripts have not been tested on Windows, and you'll find things -easiest if you build Anki using WSL. -https://docs.microsoft.com/en-us/windows/wsl/install-win10 +The build process uses a GNU makefile, so you'll either need to run +GNU make via WSL (https://docs.microsoft.com/en-us/windows/wsl/install-win10) +or Cygwin, or manually execute the build steps. From 94485f36aeefb2b2ba81fe7445c9915594f14d22 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 25 Dec 2019 13:01:19 +1000 Subject: [PATCH 33/42] fix handling of typing cards --- rs/ankirs/src/template.rs | 17 ++++++++++++----- tests/test_models.py | 6 ++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/rs/ankirs/src/template.rs b/rs/ankirs/src/template.rs index 89f7589e3..ab1758f74 100644 --- a/rs/ankirs/src/template.rs +++ b/rs/ankirs/src/template.rs @@ -177,7 +177,12 @@ fn template_is_empty<'a>(nonempty_fields: &HashSet<&str>, nodes: &[ParsedNode<'a match node { // ignore normal text Text(_) => (), - Replacement { key, .. } => { + Replacement { key, filters } => { + // Anki doesn't consider a type: reference as a required field + if filters.contains(&"type") { + continue; + } + if nonempty_fields.contains(*key) { // a single replacement is enough return false; @@ -307,7 +312,7 @@ mod test { let mut tmpl = PT::from_text("{{2}}{{1}}").unwrap(); assert_eq!(tmpl.renders_with_fields(&fields), true); tmpl = PT::from_text("{{2}}{{type:cloze:1}}").unwrap(); - assert_eq!(tmpl.renders_with_fields(&fields), true); + assert_eq!(tmpl.renders_with_fields(&fields), false); tmpl = PT::from_text("{{2}}{{4}}").unwrap(); assert_eq!(tmpl.renders_with_fields(&fields), false); tmpl = PT::from_text("{{#3}}{{^2}}{{1}}{{/2}}{{/3}}").unwrap(); @@ -346,8 +351,10 @@ mod test { FieldRequirements::All(HashSet::from_iter(vec![0, 1].into_iter())) ); - // fixme: handling of type in answer card reqs doesn't match desktop, - // which only requires first field - // + tmpl = PT::from_text("{{a}}{{type:b}}").unwrap(); + assert_eq!( + tmpl.requirements(&field_map), + FieldRequirements::Any(HashSet::from_iter(vec![0].into_iter())) + ); } } diff --git a/tests/test_models.py b/tests/test_models.py index 78581aa67..823be124e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -371,6 +371,12 @@ def test_req(): mm.save(opt, templates=True) assert opt['req'][1] == [1, 'none', []] + opt = mm.byName("Basic (type in the answer)") + reqSize(opt) + r = opt['req'][0] + assert r[1] in ("any", "all") + assert r[2] == [0] + # def test_updatereqs_performance(): # import time # d = getEmptyCol() From 40b5051a9285d2621e92b922e9b95a483640b759 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 25 Dec 2019 13:43:08 +1000 Subject: [PATCH 34/42] disable test around daily cutoff time --- tests/test_schedv2.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/test_schedv2.py b/tests/test_schedv2.py index f3b41fe9b..1cff4cbe6 100644 --- a/tests/test_schedv2.py +++ b/tests/test_schedv2.py @@ -705,6 +705,9 @@ def test_filt_reviewing_early_normal(): def test_filt_keep_lrn_state(): d = getEmptyCol() + if (d.sched.dayCutoff - time.time()) < 60*60*3: + print("test_filt_keep_lrn_state disabled due to time of day") + return f = d.newNote() f['Front'] = "one" From 9791bcb36b60ef3af7b4f926426687f4bac2e8ae Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 25 Dec 2019 14:08:04 +1000 Subject: [PATCH 35/42] more tests fail close to 4am; automatically adjust the time instead --- tests/test_schedv2.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/test_schedv2.py b/tests/test_schedv2.py index 1cff4cbe6..f3825027b 100644 --- a/tests/test_schedv2.py +++ b/tests/test_schedv2.py @@ -8,6 +8,14 @@ from tests.shared import getEmptyCol from anki.utils import intTime from anki.hooks import addHook +# Between 2-4AM, shift the time back so test assumptions hold. +lt = time.localtime() +if lt.tm_hour > 2 and lt.tm_hour < 4: + orig_time = time.time + def adjusted_time(): + return orig_time() - 60*60*2 + time.time = adjusted_time + def test_clock(): d = getEmptyCol() if (d.sched.dayCutoff - intTime()) < 10*60: @@ -705,9 +713,6 @@ def test_filt_reviewing_early_normal(): def test_filt_keep_lrn_state(): d = getEmptyCol() - if (d.sched.dayCutoff - time.time()) < 60*60*3: - print("test_filt_keep_lrn_state disabled due to time of day") - return f = d.newNote() f['Front'] = "one" From e5c4618a9af37e926f61624b95f4d31b43efde3a Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 25 Dec 2019 14:18:34 +1000 Subject: [PATCH 36/42] format tests --- Makefile | 2 +- tests/shared.py | 4 + tests/test_addons.py | 35 +--- tests/test_cards.py | 46 ++--- tests/test_collection.py | 56 ++++--- tests/test_decks.py | 55 +++--- tests/test_exporting.py | 31 ++-- tests/test_find.py | 129 +++++++++------ tests/test_flags.py | 4 +- tests/test_importing.py | 64 ++++--- tests/test_latex.py | 15 +- tests/test_media.py | 35 ++-- tests/test_models.py | 242 +++++++++++++++------------ tests/test_schedv1.py | 274 +++++++++++++++++------------- tests/test_schedv2.py | 350 +++++++++++++++++++++++---------------- tests/test_stats.py | 10 +- tests/test_template.py | 15 +- tests/test_undo.py | 18 +- tests/test_utils.py | 1 + 19 files changed, 801 insertions(+), 585 deletions(-) diff --git a/Makefile b/Makefile index 9cae8398a..791b9df50 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ MAKEFLAGS += --warn-undefined-variables MAKEFLAGS += --no-builtin-rules RUNARGS := .SUFFIXES: -BLACKARGS := -t py36 anki aqt +BLACKARGS := -t py36 anki aqt tests RUSTARGS := --release --strip $(shell mkdir -p .build) diff --git a/tests/shared.py b/tests/shared.py index 70acfe4c0..1ddf13601 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -1,6 +1,7 @@ import tempfile, os, shutil from anki import Collection as aopen + def assertException(exception, func): found = False try: @@ -25,6 +26,7 @@ def getEmptyCol(): col = aopen(nam) return col + getEmptyCol.master = "" # Fallback for when the DB needs options passed in. @@ -34,10 +36,12 @@ def getEmptyDeckWith(**kwargs): os.unlink(nam) return aopen(nam, **kwargs) + def getUpgradeDeckPath(name="anki12.anki"): src = os.path.join(testDir, "support", name) (fd, dst) = tempfile.mkstemp(suffix=".anki2") shutil.copy(src, dst) return dst + testDir = os.path.dirname(__file__) diff --git a/tests/test_addons.py b/tests/test_addons.py index 954ea8594..69bc54245 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -9,65 +9,48 @@ from aqt.addons import AddonManager def test_readMinimalManifest(): assertReadManifest( - '{"package": "yes", "name": "no"}', - {"package": "yes", "name": "no"} + '{"package": "yes", "name": "no"}', {"package": "yes", "name": "no"} ) def test_readExtraKeys(): assertReadManifest( '{"package": "a", "name": "b", "mod": 3, "conflicts": ["d", "e"]}', - {"package": "a", "name": "b", "mod": 3, "conflicts": ["d", "e"]} + {"package": "a", "name": "b", "mod": 3, "conflicts": ["d", "e"]}, ) def test_invalidManifest(): - assertReadManifest( - '{"one": 1}', - {} - ) + assertReadManifest('{"one": 1}', {}) def test_mustHaveName(): - assertReadManifest( - '{"package": "something"}', - {} - ) + assertReadManifest('{"package": "something"}', {}) def test_mustHavePackage(): - assertReadManifest( - '{"name": "something"}', - {} - ) + assertReadManifest('{"name": "something"}', {}) def test_invalidJson(): - assertReadManifest( - 'this is not a JSON dictionary', - {} - ) + assertReadManifest("this is not a JSON dictionary", {}) def test_missingManifest(): assertReadManifest( - '{"package": "what", "name": "ever"}', - {}, - nameInZip="not-manifest.bin" + '{"package": "what", "name": "ever"}', {}, nameInZip="not-manifest.bin" ) def test_ignoreExtraKeys(): assertReadManifest( - '{"package": "a", "name": "b", "game": "c"}', - {"package": "a", "name": "b"} + '{"package": "a", "name": "b", "game": "c"}', {"package": "a", "name": "b"} ) def test_conflictsMustBeStrings(): assertReadManifest( - '{"package": "a", "name": "b", "conflicts": ["c", 4, {"d": "e"}]}', - {} + '{"package": "a", "name": "b", "conflicts": ["c", 4, {"d": "e"}]}', {} ) diff --git a/tests/test_cards.py b/tests/test_cards.py index b0732b80b..6e519c84f 100644 --- a/tests/test_cards.py +++ b/tests/test_cards.py @@ -2,11 +2,12 @@ from tests.shared import getEmptyCol + def test_previewCards(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" # non-empty and active cards = deck.previewCards(f, 0) assert len(cards) == 1 @@ -22,11 +23,12 @@ def test_previewCards(): # make sure we haven't accidentally added cards to the db assert deck.cardCount() == 1 + def test_delete(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" deck.addNote(f) cid = f.cards()[0].id deck.reset() @@ -38,62 +40,65 @@ def test_delete(): assert deck.db.scalar("select count() from cards") == 0 assert deck.db.scalar("select count() from graves") == 2 + def test_misc(): d = getEmptyCol() f = d.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" d.addNote(f) c = f.cards()[0] - id = d.models.current()['id'] - assert c.template()['ord'] == 0 + id = d.models.current()["id"] + assert c.template()["ord"] == 0 + def test_genrem(): d = getEmptyCol() f = d.newNote() - f['Front'] = '1' - f['Back'] = '' + f["Front"] = "1" + f["Back"] = "" d.addNote(f) assert len(f.cards()) == 1 m = d.models.current() mm = d.models # adding a new template should automatically create cards t = mm.newTemplate("rev") - t['qfmt'] = '{{Front}}' - t['afmt'] = "" + t["qfmt"] = "{{Front}}" + t["afmt"] = "" mm.addTemplate(m, t) mm.save(m, templates=True) assert len(f.cards()) == 2 # if the template is changed to remove cards, they'll be removed - t['qfmt'] = "{{Back}}" + t["qfmt"] = "{{Back}}" mm.save(m, templates=True) d.remCards(d.emptyCids()) assert len(f.cards()) == 1 # if we add to the note, a card should be automatically generated f.load() - f['Back'] = "1" + f["Back"] = "1" f.flush() assert len(f.cards()) == 2 + def test_gendeck(): d = getEmptyCol() cloze = d.models.byName("Cloze") d.models.setCurrent(cloze) f = d.newNote() - f['Text'] = '{{c1::one}}' + f["Text"] = "{{c1::one}}" d.addNote(f) assert d.cardCount() == 1 assert f.cards()[0].did == 1 # set the model to a new default deck newId = d.decks.id("new") - cloze['did'] = newId + cloze["did"] = newId d.models.save(cloze, updateReqs=False) # a newly generated card should share the first card's deck - f['Text'] += '{{c2::two}}' + f["Text"] += "{{c2::two}}" f.flush() assert f.cards()[1].did == 1 # and same with multiple cards - f['Text'] += '{{c3::three}}' + f["Text"] += "{{c3::three}}" f.flush() assert f.cards()[2].did == 1 # if one of the cards is in a different deck, it should revert to the @@ -101,9 +106,6 @@ def test_gendeck(): c = f.cards()[1] c.did = newId c.flush() - f['Text'] += '{{c4::four}}' + f["Text"] += "{{c4::four}}" f.flush() assert f.cards()[3].did == newId - - - diff --git a/tests/test_collection.py b/tests/test_collection.py index 37304cd47..1868cf8b9 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -8,6 +8,7 @@ from anki.stdmodels import addBasicModel, models from anki import Collection as aopen + def test_create_open(): (fd, path) = tempfile.mkstemp(suffix=".anki2", prefix="test_attachNew") try: @@ -32,27 +33,28 @@ def test_create_open(): dir = "c:\root.anki2" else: dir = "/attachroot.anki2" - assertException(Exception, - lambda: aopen(dir)) + assertException(Exception, lambda: aopen(dir)) # reuse tmp file from before, test non-writeable file os.chmod(newPath, 0) - assertException(Exception, - lambda: aopen(newPath)) + assertException(Exception, lambda: aopen(newPath)) os.chmod(newPath, 0o666) os.unlink(newPath) + def test_noteAddDelete(): deck = getEmptyCol() # add a note f = deck.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" n = deck.addNote(f) assert n == 1 # test multiple cards - add another template - m = deck.models.current(); mm = deck.models + m = deck.models.current() + mm = deck.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) mm.save(m) # the default save doesn't generate cards @@ -63,7 +65,8 @@ def test_noteAddDelete(): assert deck.cardCount() == 2 # creating new notes should use both cards f = deck.newNote() - f['Front'] = "three"; f['Back'] = "four" + f["Front"] = "three" + f["Back"] = "four" n = deck.addNote(f) assert n == 2 assert deck.cardCount() == 4 @@ -74,36 +77,39 @@ def test_noteAddDelete(): assert not f.dupeOrEmpty() # now let's make a duplicate f2 = deck.newNote() - f2['Front'] = "one"; f2['Back'] = "" + f2["Front"] = "one" + f2["Back"] = "" assert f2.dupeOrEmpty() # empty first field should not be permitted either - f2['Front'] = " " + f2["Front"] = " " assert f2.dupeOrEmpty() + def test_fieldChecksum(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = "new"; f['Back'] = "new2" + f["Front"] = "new" + f["Back"] = "new2" deck.addNote(f) - assert deck.db.scalar( - "select csum from notes") == int("c2a6b03f", 16) + assert deck.db.scalar("select csum from notes") == int("c2a6b03f", 16) # changing the val should change the checksum - f['Front'] = "newx" + f["Front"] = "newx" f.flush() - assert deck.db.scalar( - "select csum from notes") == int("302811ae", 16) + assert deck.db.scalar("select csum from notes") == int("302811ae", 16) + def test_addDelTags(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = "1" + f["Front"] = "1" deck.addNote(f) f2 = deck.newNote() - f2['Front'] = "2" + f2["Front"] = "2" deck.addNote(f2) # adding for a given id deck.tags.bulkAdd([f.id], "foo") - f.load(); f2.load() + f.load() + f2.load() assert "foo" in f.tags assert "foo" not in f2.tags # should be canonified @@ -112,6 +118,7 @@ def test_addDelTags(): assert f.tags[0] == "aaa" assert len(f.tags) == 2 + def test_timestamps(): deck = getEmptyCol() assert len(deck.models.models) == len(models) @@ -119,23 +126,24 @@ def test_timestamps(): addBasicModel(deck) assert len(deck.models.models) == 100 + len(models) + def test_furigana(): deck = getEmptyCol() mm = deck.models m = mm.current() # filter should work - m['tmpls'][0]['qfmt'] = '{{kana:Front}}' + m["tmpls"][0]["qfmt"] = "{{kana:Front}}" mm.save(m) n = deck.newNote() - n['Front'] = 'foo[abc]' + n["Front"] = "foo[abc]" deck.addNote(n) c = n.cards()[0] assert c.q().endswith("abc") # and should avoid sound - n['Front'] = 'foo[sound:abc.mp3]' + n["Front"] = "foo[sound:abc.mp3]" n.flush() assert "sound:" in c.q(reload=True) # it shouldn't throw an error while people are editing - m['tmpls'][0]['qfmt'] = '{{kana:}}' + m["tmpls"][0]["qfmt"] = "{{kana:}}" mm.save(m) c.q(reload=True) diff --git a/tests/test_decks.py b/tests/test_decks.py index 86aa8f50c..b53510d27 100644 --- a/tests/test_decks.py +++ b/tests/test_decks.py @@ -3,6 +3,7 @@ from anki.errors import DeckRenameError from tests.shared import assertException, getEmptyCol + def test_basic(): deck = getEmptyCol() # we start with a standard deck @@ -34,21 +35,22 @@ def test_basic(): # parents with a different case should be handled correctly deck.decks.id("ONE") m = deck.models.current() - m['did'] = deck.decks.id("one::two") + m["did"] = deck.decks.id("one::two") deck.models.save(m, updateReqs=False) n = deck.newNote() - n['Front'] = "abc" + n["Front"] = "abc" deck.addNote(n) # this will error if child and parent case don't match deck.sched.deckDueList() + def test_remove(): deck = getEmptyCol() # create a new deck, and add a note/card to it g1 = deck.decks.id("g1") f = deck.newNote() - f['Front'] = "1" - f.model()['did'] = g1 + f["Front"] = "1" + f.model()["did"] = g1 deck.addNote(f) c = f.cards()[0] assert c.did == g1 @@ -62,12 +64,14 @@ def test_remove(): assert deck.decks.name(c.did) == "[no deck]" # let's create another deck and explicitly set the card to it g2 = deck.decks.id("g2") - c.did = g2; c.flush() + c.did = g2 + c.flush() # this time we'll delete the card/note too deck.decks.rem(g2, cardsToo=True) assert deck.cardCount() == 0 assert deck.noteCount() == 0 + def test_rename(): d = getEmptyCol() id = d.decks.id("hello::world") @@ -80,8 +84,7 @@ def test_rename(): # create another deck id = d.decks.id("tmp") # we can't rename it if it conflicts - assertException( - Exception, lambda: d.decks.rename(d.decks.get(id), "foo")) + assertException(Exception, lambda: d.decks.rename(d.decks.get(id), "foo")) # when renaming, the children should be renamed too d.decks.id("one::two::three") id = d.decks.id("one") @@ -102,62 +105,66 @@ def test_rename(): assertException(DeckRenameError, lambda: d.decks.rename(child, "PARENT::child")) - def test_renameForDragAndDrop(): d = getEmptyCol() def deckNames(): - return [ name for name in sorted(d.decks.allNames()) if name != 'Default' ] + return [name for name in sorted(d.decks.allNames()) if name != "Default"] - languages_did = d.decks.id('Languages') - chinese_did = d.decks.id('Chinese') - hsk_did = d.decks.id('Chinese::HSK') + languages_did = d.decks.id("Languages") + chinese_did = d.decks.id("Chinese") + hsk_did = d.decks.id("Chinese::HSK") # Renaming also renames children d.decks.renameForDragAndDrop(chinese_did, languages_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::Chinese::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::Chinese::HSK"] # Dragging a deck onto itself is a no-op d.decks.renameForDragAndDrop(languages_did, languages_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::Chinese::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::Chinese::HSK"] # Dragging a deck onto its parent is a no-op d.decks.renameForDragAndDrop(hsk_did, chinese_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::Chinese::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::Chinese::HSK"] # Dragging a deck onto a descendant is a no-op d.decks.renameForDragAndDrop(languages_did, hsk_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::Chinese::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::Chinese::HSK"] # Can drag a grandchild onto its grandparent. It becomes a child d.decks.renameForDragAndDrop(hsk_did, languages_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::HSK"] # Can drag a deck onto its sibling d.decks.renameForDragAndDrop(hsk_did, chinese_did) - assert deckNames() == [ 'Languages', 'Languages::Chinese', 'Languages::Chinese::HSK' ] + assert deckNames() == ["Languages", "Languages::Chinese", "Languages::Chinese::HSK"] # Can drag a deck back to the top level d.decks.renameForDragAndDrop(chinese_did, None) - assert deckNames() == [ 'Chinese', 'Chinese::HSK', 'Languages' ] + assert deckNames() == ["Chinese", "Chinese::HSK", "Languages"] # Dragging a top level deck to the top level is a no-op d.decks.renameForDragAndDrop(chinese_did, None) - assert deckNames() == [ 'Chinese', 'Chinese::HSK', 'Languages' ] + assert deckNames() == ["Chinese", "Chinese::HSK", "Languages"] # can't drack a deck where sibling have same name new_hsk_did = d.decks.id("HSK") - assertException(DeckRenameError, lambda: d.decks.renameForDragAndDrop(new_hsk_did, chinese_did)) + assertException( + DeckRenameError, lambda: d.decks.renameForDragAndDrop(new_hsk_did, chinese_did) + ) d.decks.rem(new_hsk_did) # can't drack a deck where sibling have same name different case new_hsk_did = d.decks.id("hsk") - assertException(DeckRenameError, lambda: d.decks.renameForDragAndDrop(new_hsk_did, chinese_did)) + assertException( + DeckRenameError, lambda: d.decks.renameForDragAndDrop(new_hsk_did, chinese_did) + ) d.decks.rem(new_hsk_did) # '' is a convenient alias for the top level DID - d.decks.renameForDragAndDrop(hsk_did, '') - assert deckNames() == [ 'Chinese', 'HSK', 'Languages' ] + d.decks.renameForDragAndDrop(hsk_did, "") + assert deckNames() == ["Chinese", "HSK", "Languages"] + def test_check(): d = getEmptyCol() diff --git a/tests/test_exporting.py b/tests/test_exporting.py index f1a14d800..f02754c3e 100644 --- a/tests/test_exporting.py +++ b/tests/test_exporting.py @@ -13,20 +13,26 @@ deck = None ds = None testDir = os.path.dirname(__file__) + def setup1(): global deck deck = getEmptyCol() f = deck.newNote() - f['Front'] = "foo"; f['Back'] = "bar
"; f.tags = ["tag", "tag2"] + f["Front"] = "foo" + f["Back"] = "bar
" + f.tags = ["tag", "tag2"] deck.addNote(f) # with a different deck f = deck.newNote() - f['Front'] = "baz"; f['Back'] = "qux" - f.model()['did'] = deck.decks.id("new deck") + f["Front"] = "baz" + f["Back"] = "qux" + f.model()["did"] = deck.decks.id("new deck") deck.addNote(f) + ########################################################################## + @with_setup(setup1) def test_export_anki(): # create a new deck with its own conf to test conf copying @@ -34,7 +40,7 @@ def test_export_anki(): dobj = deck.decks.get(did) confId = deck.decks.confId("newconf") conf = deck.decks.getConf(confId) - conf['new']['perDay'] = 5 + conf["new"]["perDay"] = 5 deck.decks.save(conf) deck.decks.setConf(dobj, confId) # export @@ -46,7 +52,7 @@ def test_export_anki(): e.exportInto(newname) # exporting should not have changed conf for original deck conf = deck.decks.confForDid(did) - assert conf['id'] != 1 + assert conf["id"] != 1 # connect to new deck d2 = aopen(newname) assert d2.cardCount() == 2 @@ -54,10 +60,10 @@ def test_export_anki(): did = d2.decks.id("test", create=False) assert did conf2 = d2.decks.confForDid(did) - assert conf2['new']['perDay'] == 20 + assert conf2["new"]["perDay"] == 20 dobj = d2.decks.get(did) # conf should be 1 - assert dobj['conf'] == 1 + assert dobj["conf"] == 1 # try again, limited to a deck fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".anki2") newname = str(newname) @@ -68,13 +74,14 @@ def test_export_anki(): d2 = aopen(newname) assert d2.cardCount() == 1 + @with_setup(setup1) def test_export_ankipkg(): # add a test file to the media folder with open(os.path.join(deck.media.dir(), "今日.mp3"), "w") as f: f.write("test") n = deck.newNote() - n['Front'] = '[sound:今日.mp3]' + n["Front"] = "[sound:今日.mp3]" deck.addNote(n) e = AnkiPackageExporter(deck) fd, newname = tempfile.mkstemp(prefix="ankitest", suffix=".apkg") @@ -83,13 +90,14 @@ def test_export_ankipkg(): os.unlink(newname) e.exportInto(newname) + @with_setup(setup1) def test_export_anki_due(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = "foo" + f["Front"] = "foo" deck.addNote(f) - deck.crt -= 86400*10 + deck.crt -= 86400 * 10 deck.sched.reset() c = deck.sched.getCard() deck.sched.answerCard(c, 3) @@ -115,6 +123,7 @@ def test_export_anki_due(): deck2.sched.reset() assert c.due - deck2.sched.today == 1 + # @with_setup(setup1) # def test_export_textcard(): # e = TextCardExporter(deck) @@ -124,6 +133,7 @@ def test_export_anki_due(): # e.includeTags = True # e.exportInto(f) + @with_setup(setup1) def test_export_textnote(): e = TextNoteExporter(deck) @@ -138,5 +148,6 @@ def test_export_textnote(): e.exportInto(f) assert open(f).readline() == "foo\tbar\n" + def test_exporters(): assert "*.apkg" in str(exporters()) diff --git a/tests/test_find.py b/tests/test_find.py index cc7e91ede..f1a79edee 100644 --- a/tests/test_find.py +++ b/tests/test_find.py @@ -4,6 +4,7 @@ from nose2.tools.such import helper from anki.find import Finder from tests.shared import getEmptyCol + def test_parse(): f = Finder(None) assert f._tokenize("hello world") == ["hello", "world"] @@ -12,43 +13,54 @@ def test_parse(): assert f._tokenize("one --two") == ["one", "-", "two"] assert f._tokenize("one - two") == ["one", "-", "two"] assert f._tokenize("one or -two") == ["one", "or", "-", "two"] - assert f._tokenize("'hello \"world\"'") == ["hello \"world\""] + assert f._tokenize("'hello \"world\"'") == ['hello "world"'] assert f._tokenize('"hello world"') == ["hello world"] assert f._tokenize("one (two or ( three or four))") == [ - "one", "(", "two", "or", "(", "three", "or", "four", - ")", ")"] + "one", + "(", + "two", + "or", + "(", + "three", + "or", + "four", + ")", + ")", + ] assert f._tokenize("embedded'string") == ["embedded'string"] assert f._tokenize("deck:'two words'") == ["deck:two words"] + def test_findCards(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = 'dog' - f['Back'] = 'cat' + f["Front"] = "dog" + f["Back"] = "cat" f.tags.append("monkey animal_1 * %") f1id = f.id deck.addNote(f) firstCardId = f.cards()[0].id f = deck.newNote() - f['Front'] = 'goats are fun' - f['Back'] = 'sheep' + f["Front"] = "goats are fun" + f["Back"] = "sheep" f.tags.append("sheep goat horse animal11") deck.addNote(f) f2id = f.id f = deck.newNote() - f['Front'] = 'cat' - f['Back'] = 'sheep' + f["Front"] = "cat" + f["Back"] = "sheep" deck.addNote(f) catCard = f.cards()[0] - m = deck.models.current(); mm = deck.models + m = deck.models.current() + mm = deck.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) mm.save(m) f = deck.newNote() - f['Front'] = 'test' - f['Back'] = 'foo bar' + f["Front"] = "test" + f["Back"] = "foo bar" deck.addNote(f) latestCardIds = [c.id for c in f.cards()] # tag searches @@ -66,9 +78,7 @@ def test_findCards(): assert len(deck.findCards("tag:sheep -tag:monkey")) == 1 assert len(deck.findCards("-tag:sheep")) == 4 deck.tags.bulkAdd(deck.db.list("select id from notes"), "foo bar") - assert (len(deck.findCards("tag:foo")) == - len(deck.findCards("tag:bar")) == - 5) + assert len(deck.findCards("tag:foo")) == len(deck.findCards("tag:bar")) == 5 deck.tags.bulkRem(deck.db.list("select id from notes"), "foo") assert len(deck.findCards("tag:foo")) == 0 assert len(deck.findCards("tag:bar")) == 5 @@ -86,7 +96,8 @@ def test_findCards(): c.flush() assert deck.findCards("is:review") == [c.id] assert deck.findCards("is:due") == [] - c.due = 0; c.queue = 2 + c.due = 0 + c.queue = 2 c.flush() assert deck.findCards("is:due") == [c.id] assert len(deck.findCards("-is:due")) == 4 @@ -97,7 +108,7 @@ def test_findCards(): assert deck.findCards("is:suspended") == [c.id] # nids assert deck.findCards("nid:54321") == [] - assert len(deck.findCards("nid:%d"%f.id)) == 2 + assert len(deck.findCards("nid:%d" % f.id)) == 2 assert len(deck.findCards("nid:%d,%d" % (f1id, f2id))) == 2 # templates with helper.assertRaises(Exception): @@ -115,16 +126,16 @@ def test_findCards(): assert len(deck.findCards("front:do")) == 0 assert len(deck.findCards("front:*")) == 5 # ordering - deck.conf['sortType'] = "noteCrt" + deck.conf["sortType"] = "noteCrt" assert deck.findCards("front:*", order=True)[-1] in latestCardIds assert deck.findCards("", order=True)[-1] in latestCardIds - deck.conf['sortType'] = "noteFld" + deck.conf["sortType"] = "noteFld" assert deck.findCards("", order=True)[0] == catCard.id assert deck.findCards("", order=True)[-1] in latestCardIds - deck.conf['sortType'] = "cardMod" + deck.conf["sortType"] = "cardMod" assert deck.findCards("", order=True)[-1] in latestCardIds assert deck.findCards("", order=True)[0] == firstCardId - deck.conf['sortBackwards'] = True + deck.conf["sortBackwards"] = True assert deck.findCards("", order=True)[0] in latestCardIds # model assert len(deck.findCards("note:basic")) == 5 @@ -140,25 +151,26 @@ def test_findCards(): deck.findCards("deck:*cefault") # full search f = deck.newNote() - f['Front'] = 'helloworld' - f['Back'] = 'abc' + f["Front"] = "helloworld" + f["Back"] = "abc" deck.addNote(f) # as it's the sort field, it matches assert len(deck.findCards("helloworld")) == 2 - #assert len(deck.findCards("helloworld", full=True)) == 2 + # assert len(deck.findCards("helloworld", full=True)) == 2 # if we put it on the back, it won't - (f['Front'], f['Back']) = (f['Back'], f['Front']) + (f["Front"], f["Back"]) = (f["Back"], f["Front"]) f.flush() assert len(deck.findCards("helloworld")) == 0 - #assert len(deck.findCards("helloworld", full=True)) == 2 - #assert len(deck.findCards("back:helloworld", full=True)) == 2 + # assert len(deck.findCards("helloworld", full=True)) == 2 + # assert len(deck.findCards("back:helloworld", full=True)) == 2 # searching for an invalid special tag should not error with helper.assertRaises(Exception): len(deck.findCards("is:invalid")) # should be able to limit to parent deck, no children id = deck.db.scalar("select id from cards limit 1") - deck.db.execute("update cards set did = ? where id = ?", - deck.decks.id("Default::Child"), id) + deck.db.execute( + "update cards set did = ? where id = ?", deck.decks.id("Default::Child"), id + ) assert len(deck.findCards("deck:default")) == 7 assert len(deck.findCards("deck:default::child")) == 1 assert len(deck.findCards("deck:default -deck:default::*")) == 6 @@ -166,7 +178,9 @@ def test_findCards(): id = deck.db.scalar("select id from cards limit 1") deck.db.execute( "update cards set queue=2, ivl=10, reps=20, due=30, factor=2200 " - "where id = ?", id) + "where id = ?", + id, + ) assert len(deck.findCards("prop:ivl>5")) == 1 assert len(deck.findCards("prop:ivl<5")) > 1 assert len(deck.findCards("prop:ivl>=5")) == 1 @@ -205,8 +219,8 @@ def test_findCards(): # empty field assert len(deck.findCards("front:")) == 0 f = deck.newNote() - f['Front'] = '' - f['Back'] = 'abc2' + f["Front"] = "" + f["Back"] = "abc2" assert deck.addNote(f) == 1 assert len(deck.findCards("front:")) == 1 # OR searches and nesting @@ -220,8 +234,7 @@ def test_findCards(): assert len(deck.findCards("(()")) == 0 # added assert len(deck.findCards("added:0")) == 0 - deck.db.execute("update cards set id = id - 86400*1000 where id = ?", - id) + deck.db.execute("update cards set id = id - 86400*1000 where id = ?", id) assert len(deck.findCards("added:1")) == deck.cardCount() - 1 assert len(deck.findCards("added:2")) == deck.cardCount() # flag @@ -230,50 +243,58 @@ def test_findCards(): with helper.assertRaises(Exception): deck.findCards("flag:12") + def test_findReplace(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = 'foo' - f['Back'] = 'bar' + f["Front"] = "foo" + f["Back"] = "bar" deck.addNote(f) f2 = deck.newNote() - f2['Front'] = 'baz' - f2['Back'] = 'foo' + f2["Front"] = "baz" + f2["Back"] = "foo" deck.addNote(f2) nids = [f.id, f2.id] # should do nothing assert deck.findReplace(nids, "abc", "123") == 0 # global replace assert deck.findReplace(nids, "foo", "qux") == 2 - f.load(); assert f['Front'] == "qux" - f2.load(); assert f2['Back'] == "qux" + f.load() + assert f["Front"] == "qux" + f2.load() + assert f2["Back"] == "qux" # single field replace assert deck.findReplace(nids, "qux", "foo", field="Front") == 1 - f.load(); assert f['Front'] == "foo" - f2.load(); assert f2['Back'] == "qux" + f.load() + assert f["Front"] == "foo" + f2.load() + assert f2["Back"] == "qux" # regex replace assert deck.findReplace(nids, "B.r", "reg") == 0 - f.load(); assert f['Back'] != "reg" + f.load() + assert f["Back"] != "reg" assert deck.findReplace(nids, "B.r", "reg", regex=True) == 1 - f.load(); assert f['Back'] == "reg" + f.load() + assert f["Back"] == "reg" + def test_findDupes(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = 'foo' - f['Back'] = 'bar' + f["Front"] = "foo" + f["Back"] = "bar" deck.addNote(f) f2 = deck.newNote() - f2['Front'] = 'baz' - f2['Back'] = 'bar' + f2["Front"] = "baz" + f2["Back"] = "bar" deck.addNote(f2) f3 = deck.newNote() - f3['Front'] = 'quux' - f3['Back'] = 'bar' + f3["Front"] = "quux" + f3["Back"] = "bar" deck.addNote(f3) f4 = deck.newNote() - f4['Front'] = 'quuux' - f4['Back'] = 'nope' + f4["Front"] = "quuux" + f4["Back"] = "nope" deck.addNote(f4) r = deck.findDupes("Back") assert r[0][0] == "bar" diff --git a/tests/test_flags.py b/tests/test_flags.py index 11796b692..1fb988fc4 100644 --- a/tests/test_flags.py +++ b/tests/test_flags.py @@ -1,9 +1,11 @@ from tests.shared import assertException, getEmptyCol + def test_flags(): col = getEmptyCol() n = col.newNote() - n['Front'] = "one"; n['Back'] = "two" + n["Front"] = "one" + n["Back"] = "two" cnt = col.addNote(n) c = n.cards()[0] # make sure higher bits are preserved diff --git a/tests/test_importing.py b/tests/test_importing.py index 67218d994..fb51a9383 100644 --- a/tests/test_importing.py +++ b/tests/test_importing.py @@ -1,22 +1,28 @@ # coding: utf-8 -import os -from tests.shared import getUpgradeDeckPath, getEmptyCol +import os +from tests.shared import getUpgradeDeckPath, getEmptyCol from anki.utils import ids2str -from anki.importing import Anki2Importer, TextImporter, \ - SupermemoXmlImporter, MnemosyneImporter, AnkiPackageImporter +from anki.importing import ( + Anki2Importer, + TextImporter, + SupermemoXmlImporter, + MnemosyneImporter, + AnkiPackageImporter, +) testDir = os.path.dirname(__file__) -srcNotes=None -srcCards=None +srcNotes = None +srcCards = None + def test_anki2_mediadupes(): tmp = getEmptyCol() # add a note that references a sound n = tmp.newNote() - n['Front'] = "[sound:foo.mp3]" - mid = n.model()['id'] + n["Front"] = "[sound:foo.mp3]" + mid = n.model()["id"] tmp.addNote(n) # add that sound to media folder with open(os.path.join(tmp.media.dir(), "foo.mp3"), "w") as f: @@ -41,8 +47,7 @@ def test_anki2_mediadupes(): f.write("bar") imp = Anki2Importer(empty, tmp.path) imp.run() - assert sorted(os.listdir(empty.media.dir())) == [ - "foo.mp3", "foo_%s.mp3" % mid] + assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", "foo_%s.mp3" % mid] n = empty.getNote(empty.db.scalar("select id from notes")) assert "_" in n.fields[0] # if the localized media file already exists, we rewrite the note and @@ -52,25 +57,24 @@ def test_anki2_mediadupes(): f.write("bar") imp = Anki2Importer(empty, tmp.path) imp.run() - assert sorted(os.listdir(empty.media.dir())) == [ - "foo.mp3", "foo_%s.mp3" % mid] - assert sorted(os.listdir(empty.media.dir())) == [ - "foo.mp3", "foo_%s.mp3" % mid] + assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", "foo_%s.mp3" % mid] + assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", "foo_%s.mp3" % mid] n = empty.getNote(empty.db.scalar("select id from notes")) assert "_" in n.fields[0] + def test_apkg(): tmp = getEmptyCol() apkg = str(os.path.join(testDir, "support/media.apkg")) imp = AnkiPackageImporter(tmp, apkg) assert os.listdir(tmp.media.dir()) == [] imp.run() - assert os.listdir(tmp.media.dir()) == ['foo.wav'] + assert os.listdir(tmp.media.dir()) == ["foo.wav"] # importing again should be idempotent in terms of media tmp.remCards(tmp.db.list("select id from cards")) imp = AnkiPackageImporter(tmp, apkg) imp.run() - assert os.listdir(tmp.media.dir()) == ['foo.wav'] + assert os.listdir(tmp.media.dir()) == ["foo.wav"] # but if the local file has different data, it will rename tmp.remCards(tmp.db.list("select id from cards")) with open(os.path.join(tmp.media.dir(), "foo.wav"), "w") as f: @@ -79,6 +83,7 @@ def test_apkg(): imp.run() assert len(os.listdir(tmp.media.dir())) == 2 + def test_anki2_diffmodel_templates(): # different from the above as this one tests only the template text being # changed, not the number of cards/fields @@ -94,11 +99,12 @@ def test_anki2_diffmodel_templates(): imp.dupeOnSchemaChange = True imp.run() # collection should contain the note we imported - assert(dst.noteCount() == 1) + assert dst.noteCount() == 1 # the front template should contain the text added in the 2nd package - tcid = dst.findCards("")[0] # only 1 note in collection + tcid = dst.findCards("")[0] # only 1 note in collection tnote = dst.getCard(tcid).note() - assert("Changed Front Template" in dst.findTemplates(tnote)[0]['qfmt']) + assert "Changed Front Template" in dst.findTemplates(tnote)[0]["qfmt"] + def test_anki2_updates(): # create a new empty deck @@ -127,6 +133,7 @@ def test_anki2_updates(): assert dst.noteCount() == 1 assert dst.db.scalar("select flds from notes").startswith("goodbye") + def test_csv(): deck = getEmptyCol() file = str(os.path.join(testDir, "support/text-2fields.txt")) @@ -147,7 +154,7 @@ def test_csv(): n.flush() i.run() n.load() - assert n.tags == ['test'] + assert n.tags == ["test"] # if add-only mode, count will be 0 i.importMode = 1 i.run() @@ -161,6 +168,7 @@ def test_csv(): assert deck.cardCount() == 11 deck.close() + def test_csv2(): deck = getEmptyCol() mm = deck.models @@ -169,9 +177,9 @@ def test_csv2(): mm.addField(m, f) mm.save(m) n = deck.newNote() - n['Front'] = "1" - n['Back'] = "2" - n['Three'] = "3" + n["Front"] = "1" + n["Back"] = "2" + n["Three"] = "3" deck.addNote(n) # an update with unmapped fields should not clobber those fields file = str(os.path.join(testDir, "support/text-update.txt")) @@ -179,16 +187,17 @@ def test_csv2(): i.initMapping() i.run() n.load() - assert n['Front'] == "1" - assert n['Back'] == "x" - assert n['Three'] == "3" + assert n["Front"] == "1" + assert n["Back"] == "x" + assert n["Three"] == "3" deck.close() + def test_supermemo_xml_01_unicode(): deck = getEmptyCol() file = str(os.path.join(testDir, "support/supermemo1.xml")) i = SupermemoXmlImporter(deck, file) - #i.META.logToStdOutput = True + # i.META.logToStdOutput = True i.run() assert i.total == 1 cid = deck.db.scalar("select id from cards") @@ -198,6 +207,7 @@ def test_supermemo_xml_01_unicode(): assert c.reps == 7 deck.close() + def test_mnemo(): deck = getEmptyCol() file = str(os.path.join(testDir, "support/mnemo.db")) diff --git a/tests/test_latex.py b/tests/test_latex.py index 8b5937a20..5bd6c9dae 100644 --- a/tests/test_latex.py +++ b/tests/test_latex.py @@ -4,17 +4,19 @@ import os import shutil -from tests.shared import getEmptyCol +from tests.shared import getEmptyCol from anki.utils import stripHTML + def test_latex(): d = getEmptyCol() # change latex cmd to simulate broken build import anki.latex + anki.latex.pngCommands[0][0] = "nolatex" # add a note with latex f = d.newNote() - f['Front'] = "[latex]hello[/latex]" + f["Front"] = "[latex]hello[/latex]" d.addNote(f) # but since latex couldn't run, there's nothing there assert len(os.listdir(d.media.dir())) == 0 @@ -34,13 +36,13 @@ def test_latex(): assert ".png" in f.cards()[0].q() # adding new notes should cause generation on question display f = d.newNote() - f['Front'] = "[latex]world[/latex]" + f["Front"] = "[latex]world[/latex]" d.addNote(f) f.cards()[0].q() assert len(os.listdir(d.media.dir())) == 2 # another note with the same media should reuse f = d.newNote() - f['Front'] = " [latex]world[/latex]" + f["Front"] = " [latex]world[/latex]" d.addNote(f) assert len(os.listdir(d.media.dir())) == 2 oldcard = f.cards()[0] @@ -49,7 +51,7 @@ def test_latex(): # missing media will show the latex anki.latex.build = False f = d.newNote() - f['Front'] = "[latex]foo[/latex]" + f["Front"] = "[latex]foo[/latex]" d.addNote(f) assert len(os.listdir(d.media.dir())) == 2 assert stripHTML(f.cards()[0].q()) == "[latex]foo[/latex]" @@ -86,10 +88,11 @@ def test_latex(): (result, msg) = _test_includes_bad_command("\\emph") assert not result, msg + def _test_includes_bad_command(bad): d = getEmptyCol() f = d.newNote() - f['Front'] = '[latex]%s[/latex]' % bad + f["Front"] = "[latex]%s[/latex]" % bad d.addNote(f) q = f.cards()[0].q() return ("'%s' is not allowed on cards" % bad in q, "Card content: %s" % q) diff --git a/tests/test_media.py b/tests/test_media.py index e8198a543..6f23cd392 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -23,6 +23,7 @@ def test_add(): f.write("world") assert d.media.addFile(path) == "foo (1).jpg" + def test_strings(): d = getEmptyCol() mf = d.media.filesInStr @@ -31,12 +32,16 @@ def test_strings(): assert mf(mid, "aoeuao") == ["foo.jpg"] assert mf(mid, "aoeuao") == ["foo.jpg"] assert mf(mid, "aoeuao") == [ - "foo.jpg", "bar.jpg"] + "foo.jpg", + "bar.jpg", + ] assert mf(mid, "aoeuao") == ["foo.jpg"] assert mf(mid, "") == ["one", "two"] - assert mf(mid, "aoeuao") == ["foo.jpg"] - assert mf(mid, "aoeuao") == [ - "foo.jpg", "fo"] + assert mf(mid, 'aoeuao') == ["foo.jpg"] + assert mf(mid, 'aoeuao') == [ + "foo.jpg", + "fo", + ] assert mf(mid, "aou[sound:foo.mp3]aou") == ["foo.mp3"] sp = d.media.strip assert sp("aoeu") == "aoeu" @@ -47,6 +52,7 @@ def test_strings(): assert es("") == "" assert es('') == '' + def test_deckIntegration(): d = getEmptyCol() # create a media dir @@ -56,11 +62,13 @@ def test_deckIntegration(): d.media.addFile(file) # add a note which references it f = d.newNote() - f['Front'] = "one"; f['Back'] = "" + f["Front"] = "one" + f["Back"] = "" d.addNote(f) # and one which references a non-existent file f = d.newNote() - f['Front'] = "one"; f['Back'] = "" + f["Front"] = "one" + f["Back"] = "" d.addNote(f) # and add another file which isn't used with open(os.path.join(d.media.dir(), "foo.jpg"), "w") as f: @@ -70,12 +78,16 @@ def test_deckIntegration(): assert ret[0] == ["fake2.png"] assert ret[1] == ["foo.jpg"] + def test_changes(): d = getEmptyCol() + def added(): return d.media.db.execute("select fname from media where csum is not null") + def removed(): return d.media.db.execute("select fname from media where csum is null") + assert not list(added()) assert not list(removed()) # add a file @@ -97,26 +109,27 @@ def test_changes(): assert not list(removed()) # but if we add another file, it will time.sleep(1) - with open(path+"2", "w") as f: + with open(path + "2", "w") as f: f.write("yo") d.media.findChanges() assert len(list(added())) == 2 assert not list(removed()) # deletions should get noticed too time.sleep(1) - os.unlink(path+"2") + os.unlink(path + "2") d.media.findChanges() assert len(list(added())) == 1 assert len(list(removed())) == 1 + def test_illegal(): d = getEmptyCol() aString = "a:b|cd\\e/f\0g*h" good = "abcdefgh" assert d.media.stripIllegal(aString) == good for c in aString: - bad = d.media.hasIllegal("somestring"+c+"morestring") + bad = d.media.hasIllegal("somestring" + c + "morestring") if bad: - assert(c not in good) + assert c not in good else: - assert(c in good) + assert c in good diff --git a/tests/test_models.py b/tests/test_models.py index 823be124e..7deb26b44 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,39 +6,42 @@ from anki.consts import MODEL_CLOZE from anki.utils import stripHTML, joinFields, isWin import anki.template + def test_modelDelete(): deck = getEmptyCol() f = deck.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" deck.addNote(f) assert deck.cardCount() == 1 deck.models.rem(deck.models.current()) assert deck.cardCount() == 0 + def test_modelCopy(): deck = getEmptyCol() m = deck.models.current() m2 = deck.models.copy(m) - assert m2['name'] == "Basic copy" - assert m2['id'] != m['id'] - assert len(m2['flds']) == 2 - assert len(m['flds']) == 2 - assert len(m2['flds']) == len(m['flds']) - assert len(m['tmpls']) == 1 - assert len(m2['tmpls']) == 1 + assert m2["name"] == "Basic copy" + assert m2["id"] != m["id"] + assert len(m2["flds"]) == 2 + assert len(m["flds"]) == 2 + assert len(m2["flds"]) == len(m["flds"]) + assert len(m["tmpls"]) == 1 + assert len(m2["tmpls"]) == 1 assert deck.models.scmhash(m) == deck.models.scmhash(m2) + def test_fields(): d = getEmptyCol() f = d.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" d.addNote(f) m = d.models.current() # make sure renaming a field updates the templates - d.models.renameField(m, m['flds'][0], "NewFront") - assert "{{NewFront}}" in m['tmpls'][0]['qfmt'] + d.models.renameField(m, m["flds"][0], "NewFront") + assert "{{NewFront}}" in m["tmpls"][0]["qfmt"] h = d.models.scmhash(m) # add a field f = d.models.newField("foo") @@ -47,44 +50,46 @@ def test_fields(): assert d.models.scmhash(m) != h # rename it d.models.renameField(m, f, "bar") - assert d.getNote(d.models.nids(m)[0])['bar'] == '' + assert d.getNote(d.models.nids(m)[0])["bar"] == "" # delete back - d.models.remField(m, m['flds'][1]) + d.models.remField(m, m["flds"][1]) assert d.getNote(d.models.nids(m)[0]).fields == ["1", ""] # move 0 -> 1 - d.models.moveField(m, m['flds'][0], 1) + d.models.moveField(m, m["flds"][0], 1) assert d.getNote(d.models.nids(m)[0]).fields == ["", "1"] # move 1 -> 0 - d.models.moveField(m, m['flds'][1], 0) + d.models.moveField(m, m["flds"][1], 0) assert d.getNote(d.models.nids(m)[0]).fields == ["1", ""] # add another and put in middle f = d.models.newField("baz") d.models.addField(m, f) f = d.getNote(d.models.nids(m)[0]) - f['baz'] = "2" + f["baz"] = "2" f.flush() assert d.getNote(d.models.nids(m)[0]).fields == ["1", "", "2"] # move 2 -> 1 - d.models.moveField(m, m['flds'][2], 1) + d.models.moveField(m, m["flds"][2], 1) assert d.getNote(d.models.nids(m)[0]).fields == ["1", "2", ""] # move 0 -> 2 - d.models.moveField(m, m['flds'][0], 2) + d.models.moveField(m, m["flds"][0], 2) assert d.getNote(d.models.nids(m)[0]).fields == ["2", "", "1"] # move 0 -> 1 - d.models.moveField(m, m['flds'][0], 1) + d.models.moveField(m, m["flds"][0], 1) assert d.getNote(d.models.nids(m)[0]).fields == ["", "2", "1"] + def test_templates(): d = getEmptyCol() - m = d.models.current(); mm = d.models + m = d.models.current() + mm = d.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) mm.save(m) f = d.newNote() - f['Front'] = '1' - f['Back'] = '2' + f["Front"] = "1" + f["Back"] = "2" d.addNote(f) assert d.cardCount() == 2 (c, c2) = f.cards() @@ -93,11 +98,12 @@ def test_templates(): assert c2.ord == 1 # switch templates d.models.moveTemplate(m, c.template(), 1) - c.load(); c2.load() + c.load() + c2.load() assert c.ord == 1 assert c2.ord == 0 # removing a template should delete its cards - assert d.models.remTemplate(m, m['tmpls'][0]) + assert d.models.remTemplate(m, m["tmpls"][0]) assert d.cardCount() == 1 # and should have updated the other cards' ordinals c = f.cards()[0] @@ -106,64 +112,67 @@ def test_templates(): # it shouldn't be possible to orphan notes by removing templates t = mm.newTemplate("template name") mm.addTemplate(m, t) - assert not d.models.remTemplate(m, m['tmpls'][0]) + assert not d.models.remTemplate(m, m["tmpls"][0]) + def test_cloze_ordinals(): d = getEmptyCol() d.models.setCurrent(d.models.byName("Cloze")) - m = d.models.current(); mm = d.models - - #We replace the default Cloze template + m = d.models.current() + mm = d.models + + # We replace the default Cloze template t = mm.newTemplate("ChainedCloze") - t['qfmt'] = "{{text:cloze:Text}}" - t['afmt'] = "{{text:cloze:Text}}" + t["qfmt"] = "{{text:cloze:Text}}" + t["afmt"] = "{{text:cloze:Text}}" mm.addTemplate(m, t) mm.save(m) - d.models.remTemplate(m, m['tmpls'][0]) - + d.models.remTemplate(m, m["tmpls"][0]) + f = d.newNote() - f['Text'] = '{{c1::firstQ::firstA}}{{c2::secondQ::secondA}}' + f["Text"] = "{{c1::firstQ::firstA}}{{c2::secondQ::secondA}}" d.addNote(f) assert d.cardCount() == 2 (c, c2) = f.cards() # first card should have first ord assert c.ord == 0 assert c2.ord == 1 - + def test_text(): d = getEmptyCol() m = d.models.current() - m['tmpls'][0]['qfmt'] = "{{text:Front}}" + m["tmpls"][0]["qfmt"] = "{{text:Front}}" d.models.save(m) f = d.newNote() - f['Front'] = 'helloworld' + f["Front"] = "helloworld" d.addNote(f) assert "helloworld" in f.cards()[0].q() + def test_cloze(): d = getEmptyCol() d.models.setCurrent(d.models.byName("Cloze")) f = d.newNote() - assert f.model()['name'] == "Cloze" + assert f.model()["name"] == "Cloze" # a cloze model with no clozes is not empty - f['Text'] = 'nothing' + f["Text"] = "nothing" assert d.addNote(f) # try with one cloze f = d.newNote() - f['Text'] = "hello {{c1::world}}" + f["Text"] = "hello {{c1::world}}" assert d.addNote(f) == 1 assert "hello [...]" in f.cards()[0].q() assert "hello world" in f.cards()[0].a() # and with a comment f = d.newNote() - f['Text'] = "hello {{c1::world::typical}}" + f["Text"] = "hello {{c1::world::typical}}" assert d.addNote(f) == 1 assert "[typical]" in f.cards()[0].q() assert "world" in f.cards()[0].a() # and with 2 clozes f = d.newNote() - f['Text'] = "hello {{c1::world}} {{c2::bar}}" + f["Text"] = "hello {{c1::world}} {{c2::bar}}" assert d.addNote(f) == 2 (c1, c2) = f.cards() assert "[...] bar" in c1.q() @@ -173,25 +182,27 @@ def test_cloze(): # if there are multiple answers for a single cloze, they are given in a # list f = d.newNote() - f['Text'] = "a {{c1::b}} {{c1::c}}" + f["Text"] = "a {{c1::b}} {{c1::c}}" assert d.addNote(f) == 1 - assert "b c" in ( - f.cards()[0].a()) + assert "b c" in (f.cards()[0].a()) # if we add another cloze, a card should be generated cnt = d.cardCount() - f['Text'] = "{{c2::hello}} {{c1::foo}}" + f["Text"] = "{{c2::hello}} {{c1::foo}}" f.flush() assert d.cardCount() == cnt + 1 # 0 or negative indices are not supported - f['Text'] += "{{c0::zero}} {{c-1:foo}}" + f["Text"] += "{{c0::zero}} {{c-1:foo}}" f.flush() assert len(f.cards()) == 2 + def test_cloze_mathjax(): d = getEmptyCol() d.models.setCurrent(d.models.byName("Cloze")) f = d.newNote() - f['Text'] = r'{{c1::ok}} \(2^2\) {{c2::not ok}} \(2^{{c3::2}}\) \(x^3\) {{c4::blah}} {{c5::text with \(x^2\) jax}}' + f[ + "Text" + ] = r"{{c1::ok}} \(2^2\) {{c2::not ok}} \(2^{{c3::2}}\) \(x^3\) {{c4::blah}} {{c5::text with \(x^2\) jax}}" assert d.addNote(f) assert len(f.cards()) == 5 assert "class=cloze" in f.cards()[0].q() @@ -201,56 +212,70 @@ def test_cloze_mathjax(): assert "class=cloze" in f.cards()[4].q() f = d.newNote() - f['Text'] = r'\(a\) {{c1::b}} \[ {{c1::c}} \]' + f["Text"] = r"\(a\) {{c1::b}} \[ {{c1::c}} \]" assert d.addNote(f) assert len(f.cards()) == 1 - assert f.cards()[0].q().endswith('\(a\) [...] \[ [...] \]') + assert f.cards()[0].q().endswith("\(a\) [...] \[ [...] \]") def test_chained_mods(): d = getEmptyCol() d.models.setCurrent(d.models.byName("Cloze")) - m = d.models.current(); mm = d.models - - #We replace the default Cloze template + m = d.models.current() + mm = d.models + + # We replace the default Cloze template t = mm.newTemplate("ChainedCloze") - t['qfmt'] = "{{cloze:text:Text}}" - t['afmt'] = "{{cloze:text:Text}}" + t["qfmt"] = "{{cloze:text:Text}}" + t["afmt"] = "{{cloze:text:Text}}" mm.addTemplate(m, t) mm.save(m) - d.models.remTemplate(m, m['tmpls'][0]) - + d.models.remTemplate(m, m["tmpls"][0]) + f = d.newNote() - q1 = 'phrase' - a1 = 'sentence' - q2 = 'en chaine' - a2 = 'chained' - f['Text'] = "This {{c1::%s::%s}} demonstrates {{c1::%s::%s}} clozes." % (q1,a1,q2,a2) + q1 = 'phrase' + a1 = "sentence" + q2 = 'en chaine' + a2 = "chained" + f["Text"] = "This {{c1::%s::%s}} demonstrates {{c1::%s::%s}} clozes." % ( + q1, + a1, + q2, + a2, + ) assert d.addNote(f) == 1 - assert "This [sentence] demonstrates [chained] clozes." in f.cards()[0].q() - assert "This phrase demonstrates en chaine clozes." in f.cards()[0].a() + assert ( + "This [sentence] demonstrates [chained] clozes." + in f.cards()[0].q() + ) + assert ( + "This phrase demonstrates en chaine clozes." + in f.cards()[0].a() + ) + def test_modelChange(): deck = getEmptyCol() basic = deck.models.byName("Basic") cloze = deck.models.byName("Cloze") # enable second template and add a note - m = deck.models.current(); mm = deck.models + m = deck.models.current() + mm = deck.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) mm.save(m) f = deck.newNote() - f['Front'] = 'f' - f['Back'] = 'b123' + f["Front"] = "f" + f["Back"] = "b123" deck.addNote(f) # switch fields map = {0: 1, 1: 0} deck.models.change(basic, [f.id], basic, map, None) f.load() - assert f['Front'] == 'b123' - assert f['Back'] == 'f' + assert f["Front"] == "b123" + assert f["Back"] == "f" # switch cards c0 = f.cards()[0] c1 = f.cards()[1] @@ -259,7 +284,9 @@ def test_modelChange(): assert c0.ord == 0 assert c1.ord == 1 deck.models.change(basic, [f.id], basic, None, map) - f.load(); c0.load(); c1.load() + f.load() + c0.load() + c1.load() assert "f" in c0.q() assert "b123" in c1.q() assert c0.ord == 1 @@ -283,30 +310,31 @@ def test_modelChange(): # but we have two cards, as a new one was generated assert len(f.cards()) == 2 # an unmapped field becomes blank - assert f['Front'] == 'b123' - assert f['Back'] == 'f' + assert f["Front"] == "b123" + assert f["Back"] == "f" deck.models.change(basic, [f.id], basic, map, None) f.load() - assert f['Front'] == '' - assert f['Back'] == 'f' + assert f["Front"] == "" + assert f["Back"] == "f" # another note to try model conversion f = deck.newNote() - f['Front'] = 'f2' - f['Back'] = 'b2' + f["Front"] = "f2" + f["Back"] = "b2" deck.addNote(f) assert deck.models.useCount(basic) == 2 assert deck.models.useCount(cloze) == 0 map = {0: 0, 1: 1} deck.models.change(basic, [f.id], cloze, map, map) f.load() - assert f['Text'] == "f2" + assert f["Text"] == "f2" assert len(f.cards()) == 2 # back the other way, with deletion of second ord - deck.models.remTemplate(basic, basic['tmpls'][1]) + deck.models.remTemplate(basic, basic["tmpls"][1]) assert deck.db.scalar("select count() from cards where nid = ?", f.id) == 2 deck.models.change(cloze, [f.id], basic, map, map) assert deck.db.scalar("select count() from cards where nid = ?", f.id) == 1 + def test_templates(): d = dict(Foo="x", Bar="y") assert anki.template.render("{{Foo}}", d) == "x" @@ -315,68 +343,72 @@ def test_templates(): assert anki.template.render("{{#Bar}}{{#Foo}}{{Foo}}{{/Foo}}{{/Bar}}", d) == "x" assert anki.template.render("{{#Baz}}{{#Foo}}{{Foo}}{{/Foo}}{{/Baz}}", d) == "" + def test_availOrds(): d = getEmptyCol() - m = d.models.current(); mm = d.models - t = m['tmpls'][0] + m = d.models.current() + mm = d.models + t = m["tmpls"][0] f = d.newNote() - f['Front'] = "1" + f["Front"] = "1" # simple templates assert mm.availOrds(m, joinFields(f.fields)) == [0] - t['qfmt'] = "{{Back}}" + t["qfmt"] = "{{Back}}" mm.save(m, templates=True) assert not mm.availOrds(m, joinFields(f.fields)) # AND - t['qfmt'] = "{{#Front}}{{#Back}}{{Front}}{{/Back}}{{/Front}}" + t["qfmt"] = "{{#Front}}{{#Back}}{{Front}}{{/Back}}{{/Front}}" mm.save(m, templates=True) assert not mm.availOrds(m, joinFields(f.fields)) - t['qfmt'] = "{{#Front}}\n{{#Back}}\n{{Front}}\n{{/Back}}\n{{/Front}}" + t["qfmt"] = "{{#Front}}\n{{#Back}}\n{{Front}}\n{{/Back}}\n{{/Front}}" mm.save(m, templates=True) assert not mm.availOrds(m, joinFields(f.fields)) # OR - t['qfmt'] = "{{Front}}\n{{Back}}" + t["qfmt"] = "{{Front}}\n{{Back}}" mm.save(m, templates=True) assert mm.availOrds(m, joinFields(f.fields)) == [0] - t['Front'] = "" - t['Back'] = "1" + t["Front"] = "" + t["Back"] = "1" assert mm.availOrds(m, joinFields(f.fields)) == [0] + def test_req(): def reqSize(model): - if model['type'] == MODEL_CLOZE: + if model["type"] == MODEL_CLOZE: return - assert (len(model['tmpls']) == len(model['req'])) + assert len(model["tmpls"]) == len(model["req"]) d = getEmptyCol() mm = d.models basic = mm.byName("Basic") - assert 'req' in basic + assert "req" in basic reqSize(basic) - r = basic['req'][0] + r = basic["req"][0] assert r[0] == 0 assert r[1] in ("any", "all") assert r[2] == [0] opt = mm.byName("Basic (optional reversed card)") reqSize(opt) - r = opt['req'][0] + r = opt["req"][0] assert r[1] in ("any", "all") assert r[2] == [0] - assert opt['req'][1] == [1, 'all', [1, 2]] - #testing any - opt['tmpls'][1]['qfmt'] = "{{Back}}{{Add Reverse}}" + assert opt["req"][1] == [1, "all", [1, 2]] + # testing any + opt["tmpls"][1]["qfmt"] = "{{Back}}{{Add Reverse}}" mm.save(opt, templates=True) - assert opt['req'][1] == [1, 'any', [1, 2]] - #testing None - opt['tmpls'][1]['qfmt'] = "{{^Add Reverse}}{{Back}}{{/Add Reverse}}" + assert opt["req"][1] == [1, "any", [1, 2]] + # testing None + opt["tmpls"][1]["qfmt"] = "{{^Add Reverse}}{{Back}}{{/Add Reverse}}" mm.save(opt, templates=True) - assert opt['req'][1] == [1, 'none', []] + assert opt["req"][1] == [1, "none", []] opt = mm.byName("Basic (type in the answer)") reqSize(opt) - r = opt['req'][0] + r = opt["req"][0] assert r[1] in ("any", "all") assert r[2] == [0] + # def test_updatereqs_performance(): # import time # d = getEmptyCol() diff --git a/tests/test_schedv1.py b/tests/test_schedv1.py index 4a2c00b3b..ad426164d 100644 --- a/tests/test_schedv1.py +++ b/tests/test_schedv1.py @@ -5,35 +5,41 @@ import copy from anki.consts import STARTING_FACTOR from tests.shared import getEmptyCol as getEmptyColOrig -from anki.utils import intTime +from anki.utils import intTime from anki.hooks import addHook + def getEmptyCol(): col = getEmptyColOrig() col.changeSchedulerVer(1) return col + def test_clock(): d = getEmptyCol() - if (d.sched.dayCutoff - intTime()) < 10*60: + if (d.sched.dayCutoff - intTime()) < 10 * 60: raise Exception("Unit tests will fail around the day rollover.") + def checkRevIvl(d, c, targetIvl): min, max = d.sched._fuzzIvlRange(targetIvl) return min <= c.ivl <= max + def test_basics(): d = getEmptyCol() d.reset() assert not d.sched.getCard() + def test_new(): d = getEmptyCol() d.reset() assert d.sched.newCount == 0 # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() assert d.sched.newCount == 1 @@ -71,15 +77,16 @@ def test_new(): # assert qs[n] in c.q() # d.sched.answerCard(c, 2) + def test_newLimits(): d = getEmptyCol() # add some notes g2 = d.decks.id("Default::foo") for i in range(30): f = d.newNote() - f['Front'] = str(i) + f["Front"] = str(i) if i > 4: - f.model()['did'] = g2 + f.model()["did"] = g2 d.addNote(f) # give the child deck a different configuration c2 = d.decks.confId("new conf") @@ -92,33 +99,36 @@ def test_newLimits(): assert c.did == 1 # limit the parent to 10 cards, meaning we get 10 in total conf1 = d.decks.confForDid(1) - conf1['new']['perDay'] = 10 + conf1["new"]["perDay"] = 10 d.reset() assert d.sched.newCount == 10 # if we limit child to 4, we should get 9 conf2 = d.decks.confForDid(g2) - conf2['new']['perDay'] = 4 + conf2["new"]["perDay"] = 4 d.reset() assert d.sched.newCount == 9 + def test_newBoxes(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() c = d.sched.getCard() - d.sched._cardConf(c)['new']['delays'] = [1,2,3,4,5] + d.sched._cardConf(c)["new"]["delays"] = [1, 2, 3, 4, 5] d.sched.answerCard(c, 2) # should handle gracefully - d.sched._cardConf(c)['new']['delays'] = [1] + d.sched._cardConf(c)["new"]["delays"] = [1] d.sched.answerCard(c, 2) + def test_learn(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" f = d.addNote(f) # set as a learn card and rebuild queues d.db.execute("update cards set queue=0, type=0") @@ -126,12 +136,12 @@ def test_learn(): # sched.getCard should return it, since it's due in the past c = d.sched.getCard() assert c - d.sched._cardConf(c)['new']['delays'] = [0.5, 3, 10] + d.sched._cardConf(c)["new"]["delays"] = [0.5, 3, 10] # fail it d.sched.answerCard(c, 1) # it should have three reps left to graduation - assert c.left%1000 == 3 - assert c.left//1000 == 3 + assert c.left % 1000 == 3 + assert c.left // 1000 == 3 # it should by due in 30 seconds t = round(c.due - time.time()) assert t >= 25 and t <= 40 @@ -139,8 +149,8 @@ def test_learn(): d.sched.answerCard(c, 2) # it should by due in 3 minutes assert round(c.due - time.time()) in (179, 180) - assert c.left%1000 == 2 - assert c.left//1000 == 2 + assert c.left % 1000 == 2 + assert c.left // 1000 == 2 # check log is accurate log = d.db.first("select * from revlog order by id desc") assert log[3] == 2 @@ -150,8 +160,8 @@ def test_learn(): d.sched.answerCard(c, 2) # it should by due in 10 minutes assert round(c.due - time.time()) in (599, 600) - assert c.left%1000 == 1 - assert c.left//1000 == 1 + assert c.left % 1000 == 1 + assert c.left // 1000 == 1 # the next pass should graduate the card assert c.queue == 1 assert c.type == 1 @@ -159,7 +169,7 @@ def test_learn(): assert c.queue == 2 assert c.type == 2 # should be due tomorrow, with an interval of 1 - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.ivl == 1 # or normal removal c.type = 0 @@ -188,14 +198,15 @@ def test_learn(): assert c.queue == 2 assert c.due == 321 + def test_learn_collapsed(): d = getEmptyCol() # add 2 notes f = d.newNote() - f['Front'] = "1" + f["Front"] = "1" f = d.addNote(f) f = d.newNote() - f['Front'] = "2" + f["Front"] = "2" f = d.addNote(f) # set as a learn card and rebuild queues d.db.execute("update cards set queue=0, type=0") @@ -214,27 +225,28 @@ def test_learn_collapsed(): c = d.sched.getCard() assert not c.q().endswith("2") + def test_learn_day(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" f = d.addNote(f) d.sched.reset() c = d.sched.getCard() - d.sched._cardConf(c)['new']['delays'] = [1, 10, 1440, 2880] + d.sched._cardConf(c)["new"]["delays"] = [1, 10, 1440, 2880] # pass it d.sched.answerCard(c, 2) # two reps to graduate, 1 more today - assert c.left%1000 == 3 - assert c.left//1000 == 1 + assert c.left % 1000 == 3 + assert c.left // 1000 == 1 assert d.sched.counts() == (0, 1, 0) c = d.sched.getCard() ni = d.sched.nextIvl assert ni(c, 2) == 86400 # answering it will place it in queue 3 d.sched.answerCard(c, 2) - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.queue == 3 assert not d.sched.getCard() # for testing, move it back a day @@ -244,7 +256,7 @@ def test_learn_day(): assert d.sched.counts() == (0, 1, 0) c = d.sched.getCard() # nextIvl should work - assert ni(c, 2) == 86400*2 + assert ni(c, 2) == 86400 * 2 # if we fail it, it should be back in the correct queue d.sched.answerCard(c, 1) assert c.queue == 1 @@ -266,17 +278,19 @@ def test_learn_day(): c.flush() d.reset() assert d.sched.counts() == (0, 0, 1) - d.sched._cardConf(c)['lapse']['delays'] = [1440] + d.sched._cardConf(c)["lapse"]["delays"] = [1440] c = d.sched.getCard() d.sched.answerCard(c, 1) assert c.queue == 3 assert d.sched.counts() == (0, 0, 0) + def test_reviews(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) # set the card up as a review card, due 8 days ago c = f.cards()[0] @@ -295,7 +309,7 @@ def test_reviews(): ################################################## # different delay to new d.reset() - d.sched._cardConf(c)['lapse']['delays'] = [2, 20] + d.sched._cardConf(c)["lapse"]["delays"] = [2, 20] d.sched.answerCard(c, 1) assert c.queue == 1 # it should be due tomorrow, with an interval of 1 @@ -313,7 +327,7 @@ def test_reviews(): # check ests. ni = d.sched.nextIvl assert ni(c, 1) == 120 - assert ni(c, 2) == 20*60 + assert ni(c, 2) == 20 * 60 # try again with an ease of 2 instead ################################################## c = copy.copy(cardcopy) @@ -355,8 +369,10 @@ def test_reviews(): c.flush() # steup hook hooked = [] + def onLeech(card): hooked.append(1) + addHook("leech", onLeech) d.sched.answerCard(c, 1) assert hooked @@ -364,10 +380,11 @@ def test_reviews(): c.load() assert c.queue == -1 + def test_button_spacing(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # 1 day ivl review card due now c = f.cards()[0] @@ -384,13 +401,14 @@ def test_button_spacing(): assert ni(c, 3) == "3 days" assert ni(c, 4) == "4 days" + def test_overdue_lapse(): # disabled in commit 3069729776990980f34c25be66410e947e9d51a2 return d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # simulate a review that was lapsed and is now due for its normal review c = f.cards()[0] @@ -419,13 +437,15 @@ def test_overdue_lapse(): d.sched.reset() assert d.sched.counts() == (0, 0, 1) + def test_finished(): d = getEmptyCol() # nothing due assert "Congratulations" in d.sched.finishedMsg() assert "limit" not in d.sched.finishedMsg() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) # have a new card assert "new cards available" in d.sched.finishedMsg() @@ -438,44 +458,46 @@ def test_finished(): assert "Congratulations" in d.sched.finishedMsg() assert "limit" not in d.sched.finishedMsg() + def test_nextIvl(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() conf = d.decks.confForDid(1) - conf['new']['delays'] = [0.5, 3, 10] - conf['lapse']['delays'] = [1, 5, 9] + conf["new"]["delays"] = [0.5, 3, 10] + conf["lapse"]["delays"] = [1, 5, 9] c = d.sched.getCard() # new cards ################################################## ni = d.sched.nextIvl assert ni(c, 1) == 30 assert ni(c, 2) == 180 - assert ni(c, 3) == 4*86400 + assert ni(c, 3) == 4 * 86400 d.sched.answerCard(c, 1) # cards in learning ################################################## assert ni(c, 1) == 30 assert ni(c, 2) == 180 - assert ni(c, 3) == 4*86400 + assert ni(c, 3) == 4 * 86400 d.sched.answerCard(c, 2) assert ni(c, 1) == 30 assert ni(c, 2) == 600 - assert ni(c, 3) == 4*86400 + assert ni(c, 3) == 4 * 86400 d.sched.answerCard(c, 2) # normal graduation is tomorrow - assert ni(c, 2) == 1*86400 - assert ni(c, 3) == 4*86400 + assert ni(c, 2) == 1 * 86400 + assert ni(c, 3) == 4 * 86400 # lapsed cards ################################################## c.type = 2 c.ivl = 100 c.factor = STARTING_FACTOR assert ni(c, 1) == 60 - assert ni(c, 2) == 100*86400 - assert ni(c, 3) == 100*86400 + assert ni(c, 2) == 100 * 86400 + assert ni(c, 3) == 100 * 86400 # review cards ################################################## c.queue = 2 @@ -484,8 +506,8 @@ def test_nextIvl(): # failing it should put it at 60s assert ni(c, 1) == 60 # or 1 day if relearn is false - d.sched._cardConf(c)['lapse']['delays']=[] - assert ni(c, 1) == 1*86400 + d.sched._cardConf(c)["lapse"]["delays"] = [] + assert ni(c, 1) == 1 * 86400 # (* 100 1.2 86400)10368000.0 assert ni(c, 2) == 10368000 # (* 100 2.5 86400)21600000.0 @@ -494,10 +516,11 @@ def test_nextIvl(): assert ni(c, 4) == 28080000 assert d.sched.nextIvlStr(c, 4) == "10.8 months" + def test_misc(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] # burying @@ -508,10 +531,11 @@ def test_misc(): d.reset() assert d.sched.getCard() + def test_suspend(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] # suspending @@ -525,7 +549,11 @@ def test_suspend(): d.reset() assert d.sched.getCard() # should cope with rev cards being relearnt - c.due = 0; c.ivl = 100; c.type = 2; c.queue = 2; c.flush() + c.due = 0 + c.ivl = 100 + c.type = 2 + c.queue = 2 + c.flush() d.reset() c = d.sched.getCard() d.sched.answerCard(c, 1) @@ -551,10 +579,11 @@ def test_suspend(): assert c.due == 1 assert c.did == 1 + def test_cram(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.ivl = 100 @@ -566,7 +595,7 @@ def test_cram(): c.startTimer() c.flush() d.reset() - assert d.sched.counts() == (0,0,0) + assert d.sched.counts() == (0, 0, 0) cardcopy = copy.copy(c) # create a dynamic deck and refresh it did = d.decks.newDyn("Cram") @@ -575,18 +604,18 @@ def test_cram(): # should appear as new in the deck list assert sorted(d.sched.deckDueList())[0][4] == 1 # and should appear in the counts - assert d.sched.counts() == (1,0,0) + assert d.sched.counts() == (1, 0, 0) # grab it and check estimates c = d.sched.getCard() assert d.sched.answerButtons(c) == 2 assert d.sched.nextIvl(c, 1) == 600 - assert d.sched.nextIvl(c, 2) == 138*60*60*24 + assert d.sched.nextIvl(c, 2) == 138 * 60 * 60 * 24 cram = d.decks.get(did) - cram['delays'] = [1, 10] + cram["delays"] = [1, 10] assert d.sched.answerButtons(c) == 3 assert d.sched.nextIvl(c, 1) == 60 assert d.sched.nextIvl(c, 2) == 600 - assert d.sched.nextIvl(c, 3) == 138*60*60*24 + assert d.sched.nextIvl(c, 3) == 138 * 60 * 60 * 24 d.sched.answerCard(c, 2) # elapsed time was 75 days # factor = 2.5+1.2/2 = 1.85 @@ -595,12 +624,11 @@ def test_cram(): assert c.odue == 138 assert c.queue == 1 # should be logged as a cram rep - assert d.db.scalar( - "select type from revlog order by id desc limit 1") == 3 + assert d.db.scalar("select type from revlog order by id desc limit 1") == 3 # check ivls again assert d.sched.nextIvl(c, 1) == 60 - assert d.sched.nextIvl(c, 2) == 138*60*60*24 - assert d.sched.nextIvl(c, 3) == 138*60*60*24 + assert d.sched.nextIvl(c, 2) == 138 * 60 * 60 * 24 + assert d.sched.nextIvl(c, 3) == 138 * 60 * 60 * 24 # when it graduates, due is updated c = d.sched.getCard() d.sched.answerCard(c, 2) @@ -616,7 +644,7 @@ def test_cram(): # check ivls again - passing should be idempotent assert d.sched.nextIvl(c, 1) == 60 assert d.sched.nextIvl(c, 2) == 600 - assert d.sched.nextIvl(c, 3) == 138*60*60*24 + assert d.sched.nextIvl(c, 3) == 138 * 60 * 60 * 24 d.sched.answerCard(c, 2) assert c.ivl == 138 assert c.odue == 138 @@ -630,20 +658,20 @@ def test_cram(): assert len(d.sched.deckDueList()) == 1 c.load() assert c.ivl == 1 - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 # make it due d.reset() - assert d.sched.counts() == (0,0,0) + assert d.sched.counts() == (0, 0, 0) c.due = -5 c.ivl = 100 c.flush() d.reset() - assert d.sched.counts() == (0,0,1) + assert d.sched.counts() == (0, 0, 1) # cram again did = d.decks.newDyn("Cram") d.sched.rebuildDyn(did) d.reset() - assert d.sched.counts() == (0,0,1) + assert d.sched.counts() == (0, 0, 1) c.load() assert d.sched.answerButtons(c) == 4 # add a sibling so we can test minSpace, etc @@ -661,10 +689,11 @@ def test_cram(): # it should have been moved back to the original deck assert c.did == 1 + def test_cram_rem(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) oldDue = f.cards()[0].due did = d.decks.newDyn("Cram") @@ -681,16 +710,17 @@ def test_cram_rem(): assert c.type == c.queue == 0 assert c.due == oldDue + def test_cram_resched(): # add card d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # cram deck did = d.decks.newDyn("Cram") cram = d.decks.get(did) - cram['resched'] = False + cram["resched"] = False d.sched.rebuildDyn(did) d.reset() # graduate should return it to new @@ -786,22 +816,25 @@ def test_cram_resched(): # d.sched.answerCard(c, 2) # print c.__dict__ + def test_ordcycle(): d = getEmptyCol() # add two more templates and set second active - m = d.models.current(); mm = d.models + m = d.models.current() + mm = d.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) t = mm.newTemplate("f2") - t['qfmt'] = "{{Front}}" - t['afmt'] = "{{Back}}" + t["qfmt"] = "{{Front}}" + t["afmt"] = "{{Back}}" mm.addTemplate(m, t) mm.save(m) # create a new note; it should have 3 cards f = d.newNote() - f['Front'] = "1"; f['Back'] = "1" + f["Front"] = "1" + f["Back"] = "1" d.addNote(f) assert d.cardCount() == 3 d.reset() @@ -810,10 +843,12 @@ def test_ordcycle(): assert d.sched.getCard().ord == 1 assert d.sched.getCard().ord == 2 + def test_counts_idx(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() assert d.sched.counts() == (1, 0, 0) @@ -832,10 +867,11 @@ def test_counts_idx(): d.sched.answerCard(c, 1) assert d.sched.counts() == (0, 2, 0) + def test_repCounts(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() # lrnReps should be accurate on pass/fail @@ -853,7 +889,7 @@ def test_repCounts(): d.sched.answerCard(d.sched.getCard(), 2) assert d.sched.counts() == (0, 0, 0) f = d.newNote() - f['Front'] = "two" + f["Front"] = "two" d.addNote(f) d.reset() # initial pass should be correct too @@ -865,14 +901,14 @@ def test_repCounts(): assert d.sched.counts() == (0, 0, 0) # immediate graduate should work f = d.newNote() - f['Front'] = "three" + f["Front"] = "three" d.addNote(f) d.reset() d.sched.answerCard(d.sched.getCard(), 3) assert d.sched.counts() == (0, 0, 0) # and failing a review should too f = d.newNote() - f['Front'] = "three" + f["Front"] = "three" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -884,12 +920,13 @@ def test_repCounts(): d.sched.answerCard(d.sched.getCard(), 1) assert d.sched.counts() == (0, 1, 0) + def test_timing(): d = getEmptyCol() # add a few review cards, due today for i in range(5): f = d.newNote() - f['Front'] = "num"+str(i) + f["Front"] = "num" + str(i) d.addNote(f) c = f.cards()[0] c.type = 2 @@ -900,7 +937,7 @@ def test_timing(): d.reset() c = d.sched.getCard() # set a a fail delay of 1 second so we don't have to wait - d.sched._cardConf(c)['lapse']['delays'][0] = 1/60.0 + d.sched._cardConf(c)["lapse"]["delays"][0] = 1 / 60.0 d.sched.answerCard(c, 1) # the next card should be another review c = d.sched.getCard() @@ -910,11 +947,12 @@ def test_timing(): c = d.sched.getCard() assert c.queue == 1 + def test_collapse(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() # test collapsing @@ -924,16 +962,17 @@ def test_collapse(): d.sched.answerCard(c, 3) assert not d.sched.getCard() + def test_deckDue(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # and one that's a child f = d.newNote() - f['Front'] = "two" - default1 = f.model()['did'] = d.decks.id("Default::1") + f["Front"] = "two" + default1 = f.model()["did"] = d.decks.id("Default::1") d.addNote(f) # make it a review card c = f.cards()[0] @@ -942,13 +981,13 @@ def test_deckDue(): c.flush() # add one more with a new deck f = d.newNote() - f['Front'] = "two" - foobar = f.model()['did'] = d.decks.id("foo::bar") + f["Front"] = "two" + foobar = f.model()["did"] = d.decks.id("foo::bar") d.addNote(f) # and one that's a sibling f = d.newNote() - f['Front'] = "three" - foobaz = f.model()['did'] = d.decks.id("foo::baz") + f["Front"] = "three" + foobaz = f.model()["did"] = d.decks.id("foo::baz") d.addNote(f) d.reset() assert len(d.decks.decks) == 5 @@ -970,10 +1009,12 @@ def test_deckDue(): assert tree[0][5][0][2] == 1 assert tree[0][5][0][4] == 0 # code should not fail if a card has an invalid deck - c.did = 12345; c.flush() + c.did = 12345 + c.flush() d.sched.deckDueList() d.sched.deckDueTree() + def test_deckTree(): d = getEmptyCol() d.decks.id("new::b::c") @@ -983,75 +1024,80 @@ def test_deckTree(): names.remove("new") assert "new" not in names + def test_deckFlow(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # and one that's a child f = d.newNote() - f['Front'] = "two" - default1 = f.model()['did'] = d.decks.id("Default::2") + f["Front"] = "two" + default1 = f.model()["did"] = d.decks.id("Default::2") d.addNote(f) # and another that's higher up f = d.newNote() - f['Front'] = "three" - default1 = f.model()['did'] = d.decks.id("Default::1") + f["Front"] = "three" + default1 = f.model()["did"] = d.decks.id("Default::1") d.addNote(f) # should get top level one first, then ::1, then ::2 d.reset() - assert d.sched.counts() == (3,0,0) + assert d.sched.counts() == (3, 0, 0) for i in "one", "three", "two": c = d.sched.getCard() - assert c.note()['Front'] == i + assert c.note()["Front"] == i d.sched.answerCard(c, 2) + def test_reorder(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) f2 = d.newNote() - f2['Front'] = "two" + f2["Front"] = "two" d.addNote(f2) assert f2.cards()[0].due == 2 - found=False + found = False # 50/50 chance of being reordered for i in range(20): d.sched.randomizeCards(1) if f.cards()[0].due != f.id: - found=True + found = True break assert found d.sched.orderCards(1) assert f.cards()[0].due == 1 # shifting f3 = d.newNote() - f3['Front'] = "three" + f3["Front"] = "three" d.addNote(f3) f4 = d.newNote() - f4['Front'] = "four" + f4["Front"] = "four" d.addNote(f4) assert f.cards()[0].due == 1 assert f2.cards()[0].due == 2 assert f3.cards()[0].due == 3 assert f4.cards()[0].due == 4 - d.sched.sortCards([ - f3.cards()[0].id, f4.cards()[0].id], start=1, shift=True) + d.sched.sortCards([f3.cards()[0].id, f4.cards()[0].id], start=1, shift=True) assert f.cards()[0].due == 3 assert f2.cards()[0].due == 4 assert f3.cards()[0].due == 1 assert f4.cards()[0].due == 2 + def test_forget(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] - c.queue = 2; c.type = 2; c.ivl = 100; c.due = 0 + c.queue = 2 + c.type = 2 + c.ivl = 100 + c.due = 0 c.flush() d.reset() assert d.sched.counts() == (0, 0, 1) @@ -1059,10 +1105,11 @@ def test_forget(): d.reset() assert d.sched.counts() == (1, 0, 0) + def test_resched(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] d.sched.reschedCards([c.id], 0, 0) @@ -1072,14 +1119,15 @@ def test_resched(): assert c.queue == c.type == 2 d.sched.reschedCards([c.id], 1, 1) c.load() - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.ivl == +1 + def test_norelearn(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -1093,13 +1141,15 @@ def test_norelearn(): c.flush() d.reset() d.sched.answerCard(c, 1) - d.sched._cardConf(c)['lapse']['delays'] = [] + d.sched._cardConf(c)["lapse"]["delays"] = [] d.sched.answerCard(c, 1) + def test_failmult(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -1111,7 +1161,7 @@ def test_failmult(): c.lapses = 1 c.startTimer() c.flush() - d.sched._cardConf(c)['lapse']['mult'] = 0.5 + d.sched._cardConf(c)["lapse"]["mult"] = 0.5 c = d.sched.getCard() d.sched.answerCard(c, 1) assert c.ivl == 50 diff --git a/tests/test_schedv2.py b/tests/test_schedv2.py index f3825027b..a1eb012cd 100644 --- a/tests/test_schedv2.py +++ b/tests/test_schedv2.py @@ -5,38 +5,45 @@ import copy from anki.consts import STARTING_FACTOR from tests.shared import getEmptyCol -from anki.utils import intTime +from anki.utils import intTime from anki.hooks import addHook # Between 2-4AM, shift the time back so test assumptions hold. lt = time.localtime() if lt.tm_hour > 2 and lt.tm_hour < 4: orig_time = time.time + def adjusted_time(): - return orig_time() - 60*60*2 + return orig_time() - 60 * 60 * 2 + time.time = adjusted_time + def test_clock(): d = getEmptyCol() - if (d.sched.dayCutoff - intTime()) < 10*60: + if (d.sched.dayCutoff - intTime()) < 10 * 60: raise Exception("Unit tests will fail around the day rollover.") + def checkRevIvl(d, c, targetIvl): min, max = d.sched._fuzzIvlRange(targetIvl) return min <= c.ivl <= max + def test_basics(): d = getEmptyCol() d.reset() assert not d.sched.getCard() + def test_new(): d = getEmptyCol() d.reset() assert d.sched.newCount == 0 # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() assert d.sched.newCount == 1 @@ -74,15 +81,16 @@ def test_new(): # assert qs[n] in c.q() # d.sched.answerCard(c, 2) + def test_newLimits(): d = getEmptyCol() # add some notes g2 = d.decks.id("Default::foo") for i in range(30): f = d.newNote() - f['Front'] = str(i) + f["Front"] = str(i) if i > 4: - f.model()['did'] = g2 + f.model()["did"] = g2 d.addNote(f) # give the child deck a different configuration c2 = d.decks.confId("new conf") @@ -95,33 +103,36 @@ def test_newLimits(): assert c.did == 1 # limit the parent to 10 cards, meaning we get 10 in total conf1 = d.decks.confForDid(1) - conf1['new']['perDay'] = 10 + conf1["new"]["perDay"] = 10 d.reset() assert d.sched.newCount == 10 # if we limit child to 4, we should get 9 conf2 = d.decks.confForDid(g2) - conf2['new']['perDay'] = 4 + conf2["new"]["perDay"] = 4 d.reset() assert d.sched.newCount == 9 + def test_newBoxes(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() c = d.sched.getCard() - d.sched._cardConf(c)['new']['delays'] = [1,2,3,4,5] + d.sched._cardConf(c)["new"]["delays"] = [1, 2, 3, 4, 5] d.sched.answerCard(c, 2) # should handle gracefully - d.sched._cardConf(c)['new']['delays'] = [1] + d.sched._cardConf(c)["new"]["delays"] = [1] d.sched.answerCard(c, 2) + def test_learn(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" f = d.addNote(f) # set as a learn card and rebuild queues d.db.execute("update cards set queue=0, type=0") @@ -129,12 +140,12 @@ def test_learn(): # sched.getCard should return it, since it's due in the past c = d.sched.getCard() assert c - d.sched._cardConf(c)['new']['delays'] = [0.5, 3, 10] + d.sched._cardConf(c)["new"]["delays"] = [0.5, 3, 10] # fail it d.sched.answerCard(c, 1) # it should have three reps left to graduation - assert c.left%1000 == 3 - assert c.left//1000 == 3 + assert c.left % 1000 == 3 + assert c.left // 1000 == 3 # it should by due in 30 seconds t = round(c.due - time.time()) assert t >= 25 and t <= 40 @@ -142,9 +153,9 @@ def test_learn(): d.sched.answerCard(c, 3) # it should by due in 3 minutes dueIn = c.due - time.time() - assert 179 <= dueIn <= 180*1.25 - assert c.left%1000 == 2 - assert c.left//1000 == 2 + assert 179 <= dueIn <= 180 * 1.25 + assert c.left % 1000 == 2 + assert c.left // 1000 == 2 # check log is accurate log = d.db.first("select * from revlog order by id desc") assert log[3] == 3 @@ -154,9 +165,9 @@ def test_learn(): d.sched.answerCard(c, 3) # it should by due in 10 minutes dueIn = c.due - time.time() - assert 599 <= dueIn <= 600*1.25 - assert c.left%1000 == 1 - assert c.left//1000 == 1 + assert 599 <= dueIn <= 600 * 1.25 + assert c.left % 1000 == 1 + assert c.left // 1000 == 1 # the next pass should graduate the card assert c.queue == 1 assert c.type == 1 @@ -164,7 +175,7 @@ def test_learn(): assert c.queue == 2 assert c.type == 2 # should be due tomorrow, with an interval of 1 - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.ivl == 1 # or normal removal c.type = 0 @@ -176,10 +187,11 @@ def test_learn(): # revlog should have been updated each time assert d.db.scalar("select count() from revlog where type = 0") == 5 + def test_relearn(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.ivl = 100 @@ -201,10 +213,11 @@ def test_relearn(): assert c.ivl == 2 assert c.due == d.sched.today + c.ivl + def test_relearn_no_steps(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.ivl = 100 @@ -213,7 +226,7 @@ def test_relearn_no_steps(): c.flush() conf = d.decks.confForDid(1) - conf['lapse']['delays'] = [] + conf["lapse"]["delays"] = [] d.decks.save(conf) # fail the card @@ -222,14 +235,15 @@ def test_relearn_no_steps(): d.sched.answerCard(c, 1) assert c.type == c.queue == 2 + def test_learn_collapsed(): d = getEmptyCol() # add 2 notes f = d.newNote() - f['Front'] = "1" + f["Front"] = "1" f = d.addNote(f) f = d.newNote() - f['Front'] = "2" + f["Front"] = "2" f = d.addNote(f) # set as a learn card and rebuild queues d.db.execute("update cards set queue=0, type=0") @@ -248,27 +262,28 @@ def test_learn_collapsed(): c = d.sched.getCard() assert not c.q().endswith("2") + def test_learn_day(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" f = d.addNote(f) d.sched.reset() c = d.sched.getCard() - d.sched._cardConf(c)['new']['delays'] = [1, 10, 1440, 2880] + d.sched._cardConf(c)["new"]["delays"] = [1, 10, 1440, 2880] # pass it d.sched.answerCard(c, 3) # two reps to graduate, 1 more today - assert c.left%1000 == 3 - assert c.left//1000 == 1 + assert c.left % 1000 == 3 + assert c.left // 1000 == 1 assert d.sched.counts() == (0, 1, 0) c = d.sched.getCard() ni = d.sched.nextIvl assert ni(c, 3) == 86400 # answering it will place it in queue 3 d.sched.answerCard(c, 3) - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.queue == 3 assert not d.sched.getCard() # for testing, move it back a day @@ -278,7 +293,7 @@ def test_learn_day(): assert d.sched.counts() == (0, 1, 0) c = d.sched.getCard() # nextIvl should work - assert ni(c, 3) == 86400*2 + assert ni(c, 3) == 86400 * 2 # if we fail it, it should be back in the correct queue d.sched.answerCard(c, 1) assert c.queue == 1 @@ -300,17 +315,19 @@ def test_learn_day(): c.flush() d.reset() assert d.sched.counts() == (0, 0, 1) - d.sched._cardConf(c)['lapse']['delays'] = [1440] + d.sched._cardConf(c)["lapse"]["delays"] = [1440] c = d.sched.getCard() d.sched.answerCard(c, 1) assert c.queue == 3 assert d.sched.counts() == (0, 0, 0) + def test_reviews(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) # set the card up as a review card, due 8 days ago c = f.cards()[0] @@ -367,8 +384,10 @@ def test_reviews(): c.flush() # steup hook hooked = [] + def onLeech(card): hooked.append(1) + addHook("leech", onLeech) d.sched.answerCard(c, 1) assert hooked @@ -376,6 +395,7 @@ def test_reviews(): c.load() assert c.queue == -1 + def test_review_limits(): d = getEmptyCol() @@ -385,21 +405,22 @@ def test_review_limits(): pconf = d.decks.getConf(d.decks.confId("parentConf")) cconf = d.decks.getConf(d.decks.confId("childConf")) - pconf['rev']['perDay'] = 5 + pconf["rev"]["perDay"] = 5 d.decks.updateConf(pconf) - d.decks.setConf(parent, pconf['id']) - cconf['rev']['perDay'] = 10 + d.decks.setConf(parent, pconf["id"]) + cconf["rev"]["perDay"] = 10 d.decks.updateConf(cconf) - d.decks.setConf(child, cconf['id']) + d.decks.setConf(child, cconf["id"]) m = d.models.current() - m['did'] = child['id'] + m["did"] = child["id"] d.models.save(m, updateReqs=False) # add some cards for i in range(20): f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) # make them reviews @@ -410,11 +431,11 @@ def test_review_limits(): tree = d.sched.deckDueTree() # (('Default', 1, 0, 0, 0, ()), ('parent', 1514457677462, 5, 0, 0, (('child', 1514457677463, 5, 0, 0, ()),))) - assert tree[1][2] == 5 # parent - assert tree[1][5][0][2] == 5 # child + assert tree[1][2] == 5 # parent + assert tree[1][5][0][2] == 5 # child # .counts() should match - d.decks.select(child['id']) + d.decks.select(child["id"]) d.sched.reset() assert d.sched.counts() == (0, 0, 5) @@ -424,24 +445,25 @@ def test_review_limits(): assert d.sched.counts() == (0, 0, 4) tree = d.sched.deckDueTree() - assert tree[1][2] == 4 # parent - assert tree[1][5][0][2] == 4 # child + assert tree[1][2] == 4 # parent + assert tree[1][5][0][2] == 4 # child # switch limits - d.decks.setConf(parent, cconf['id']) - d.decks.setConf(child, pconf['id']) - d.decks.select(parent['id']) + d.decks.setConf(parent, cconf["id"]) + d.decks.setConf(child, pconf["id"]) + d.decks.select(parent["id"]) d.sched.reset() # child limits do not affect the parent tree = d.sched.deckDueTree() - assert tree[1][2] == 9 # parent - assert tree[1][5][0][2] == 4 # child + assert tree[1][2] == 9 # parent + assert tree[1][5][0][2] == 4 # child + def test_button_spacing(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # 1 day ivl review card due now c = f.cards()[0] @@ -460,16 +482,17 @@ def test_button_spacing(): # if hard factor is <= 1, then hard may not increase conf = d.decks.confForDid(1) - conf['rev']['hardFactor'] = 1 + conf["rev"]["hardFactor"] = 1 assert ni(c, 2) == "1 day" + def test_overdue_lapse(): # disabled in commit 3069729776990980f34c25be66410e947e9d51a2 return d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # simulate a review that was lapsed and is now due for its normal review c = f.cards()[0] @@ -498,13 +521,15 @@ def test_overdue_lapse(): d.sched.reset() assert d.sched.counts() == (0, 0, 1) + def test_finished(): d = getEmptyCol() # nothing due assert "Congratulations" in d.sched.finishedMsg() assert "limit" not in d.sched.finishedMsg() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) # have a new card assert "new cards available" in d.sched.finishedMsg() @@ -517,47 +542,49 @@ def test_finished(): assert "Congratulations" in d.sched.finishedMsg() assert "limit" not in d.sched.finishedMsg() + def test_nextIvl(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() conf = d.decks.confForDid(1) - conf['new']['delays'] = [0.5, 3, 10] - conf['lapse']['delays'] = [1, 5, 9] + conf["new"]["delays"] = [0.5, 3, 10] + conf["lapse"]["delays"] = [1, 5, 9] c = d.sched.getCard() # new cards ################################################## ni = d.sched.nextIvl assert ni(c, 1) == 30 - assert ni(c, 2) == (30+180)//2 + assert ni(c, 2) == (30 + 180) // 2 assert ni(c, 3) == 180 - assert ni(c, 4) == 4*86400 + assert ni(c, 4) == 4 * 86400 d.sched.answerCard(c, 1) # cards in learning ################################################## assert ni(c, 1) == 30 - assert ni(c, 2) == (30+180)//2 + assert ni(c, 2) == (30 + 180) // 2 assert ni(c, 3) == 180 - assert ni(c, 4) == 4*86400 + assert ni(c, 4) == 4 * 86400 d.sched.answerCard(c, 3) assert ni(c, 1) == 30 - assert ni(c, 2) == (180+600)//2 + assert ni(c, 2) == (180 + 600) // 2 assert ni(c, 3) == 600 - assert ni(c, 4) == 4*86400 + assert ni(c, 4) == 4 * 86400 d.sched.answerCard(c, 3) # normal graduation is tomorrow - assert ni(c, 3) == 1*86400 - assert ni(c, 4) == 4*86400 + assert ni(c, 3) == 1 * 86400 + assert ni(c, 4) == 4 * 86400 # lapsed cards ################################################## c.type = 2 c.ivl = 100 c.factor = STARTING_FACTOR assert ni(c, 1) == 60 - assert ni(c, 3) == 100*86400 - assert ni(c, 4) == 101*86400 + assert ni(c, 3) == 100 * 86400 + assert ni(c, 4) == 101 * 86400 # review cards ################################################## c.queue = 2 @@ -566,8 +593,8 @@ def test_nextIvl(): # failing it should put it at 60s assert ni(c, 1) == 60 # or 1 day if relearn is false - d.sched._cardConf(c)['lapse']['delays']=[] - assert ni(c, 1) == 1*86400 + d.sched._cardConf(c)["lapse"]["delays"] = [] + assert ni(c, 1) == 1 * 86400 # (* 100 1.2 86400)10368000.0 assert ni(c, 2) == 10368000 # (* 100 2.5 86400)21600000.0 @@ -576,14 +603,15 @@ def test_nextIvl(): assert ni(c, 4) == 28080000 assert d.sched.nextIvlStr(c, 4) == "10.8 months" + def test_bury(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] f = d.newNote() - f['Front'] = "two" + f["Front"] = "two" d.addNote(f) c2 = f.cards()[0] # burying @@ -598,11 +626,14 @@ def test_bury(): assert not d.sched.getCard() d.sched.unburyCardsForDeck(type="manual") - c.load(); assert c.queue == 0 - c2.load(); assert c2.queue == -2 + c.load() + assert c.queue == 0 + c2.load() + assert c2.queue == -2 d.sched.unburyCardsForDeck(type="siblings") - c2.load(); assert c2.queue == 0 + c2.load() + assert c2.queue == 0 d.sched.buryCards([c.id, c2.id]) d.sched.unburyCardsForDeck(type="all") @@ -611,10 +642,11 @@ def test_bury(): assert d.sched.counts() == (2, 0, 0) + def test_suspend(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] # suspending @@ -628,7 +660,11 @@ def test_suspend(): d.reset() assert d.sched.getCard() # should cope with rev cards being relearnt - c.due = 0; c.ivl = 100; c.type = 2; c.queue = 2; c.flush() + c.due = 0 + c.ivl = 100 + c.type = 2 + c.queue = 2 + c.flush() d.reset() c = d.sched.getCard() d.sched.answerCard(c, 1) @@ -656,10 +692,11 @@ def test_suspend(): assert c.did != 1 assert c.odue == 1 + def test_filt_reviewing_early_normal(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.ivl = 100 @@ -671,7 +708,7 @@ def test_filt_reviewing_early_normal(): c.startTimer() c.flush() d.reset() - assert d.sched.counts() == (0,0,0) + assert d.sched.counts() == (0, 0, 0) # create a dynamic deck and refresh it did = d.decks.newDyn("Cram") d.sched.rebuildDyn(did) @@ -679,14 +716,14 @@ def test_filt_reviewing_early_normal(): # should appear as normal in the deck list assert sorted(d.sched.deckDueList())[0][2] == 1 # and should appear in the counts - assert d.sched.counts() == (0,0,1) + assert d.sched.counts() == (0, 0, 1) # grab it and check estimates c = d.sched.getCard() assert d.sched.answerButtons(c) == 4 assert d.sched.nextIvl(c, 1) == 600 - assert d.sched.nextIvl(c, 2) == int(75*1.2)*86400 - assert d.sched.nextIvl(c, 3) == int(75*2.5)*86400 - assert d.sched.nextIvl(c, 4) == int(75*2.5*1.15)*86400 + assert d.sched.nextIvl(c, 2) == int(75 * 1.2) * 86400 + assert d.sched.nextIvl(c, 3) == int(75 * 2.5) * 86400 + assert d.sched.nextIvl(c, 4) == int(75 * 2.5 * 1.15) * 86400 # answer 'good' d.sched.answerCard(c, 3) @@ -696,8 +733,7 @@ def test_filt_reviewing_early_normal(): # should not be in learning assert c.queue == 2 # should be logged as a cram rep - assert d.db.scalar( - "select type from revlog order by id desc limit 1") == 3 + assert d.db.scalar("select type from revlog order by id desc limit 1") == 3 # due in 75 days, so it's been waiting 25 days c.ivl = 100 @@ -707,20 +743,21 @@ def test_filt_reviewing_early_normal(): d.reset() c = d.sched.getCard() - assert d.sched.nextIvl(c, 2) == 60*86400 - assert d.sched.nextIvl(c, 3) == 100*86400 - assert d.sched.nextIvl(c, 4) == 114*86400 + assert d.sched.nextIvl(c, 2) == 60 * 86400 + assert d.sched.nextIvl(c, 3) == 100 * 86400 + assert d.sched.nextIvl(c, 4) == 114 * 86400 + def test_filt_keep_lrn_state(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # fail the card outside filtered deck c = d.sched.getCard() - d.sched._cardConf(c)['new']['delays'] = [1, 10, 61] + d.sched._cardConf(c)["new"]["delays"] = [1, 10, 61] d.decks.save() d.sched.answerCard(c, 1) @@ -744,30 +781,31 @@ def test_filt_keep_lrn_state(): # should be able to advance learning steps d.sched.answerCard(c, 3) # should be due at least an hour in the future - assert c.due - intTime() > 60*60 + assert c.due - intTime() > 60 * 60 # emptying the deck preserves learning state d.sched.emptyDyn(did) c.load() assert c.type == c.queue == 1 assert c.left == 1001 - assert c.due - intTime() > 60*60 + assert c.due - intTime() > 60 * 60 + def test_preview(): # add cards d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] orig = copy.copy(c) f2 = d.newNote() - f2['Front'] = "two" + f2["Front"] = "two" d.addNote(f2) # cram deck did = d.decks.newDyn("Cram") cram = d.decks.get(did) - cram['resched'] = False + cram["resched"] = False d.sched.rebuildDyn(did) d.reset() # grab the first card @@ -801,22 +839,25 @@ def test_preview(): assert c.reps == 0 assert c.type == 0 + def test_ordcycle(): d = getEmptyCol() # add two more templates and set second active - m = d.models.current(); mm = d.models + m = d.models.current() + mm = d.models t = mm.newTemplate("Reverse") - t['qfmt'] = "{{Back}}" - t['afmt'] = "{{Front}}" + t["qfmt"] = "{{Back}}" + t["afmt"] = "{{Front}}" mm.addTemplate(m, t) t = mm.newTemplate("f2") - t['qfmt'] = "{{Front}}" - t['afmt'] = "{{Back}}" + t["qfmt"] = "{{Front}}" + t["afmt"] = "{{Back}}" mm.addTemplate(m, t) mm.save(m) # create a new note; it should have 3 cards f = d.newNote() - f['Front'] = "1"; f['Back'] = "1" + f["Front"] = "1" + f["Back"] = "1" d.addNote(f) assert d.cardCount() == 3 d.reset() @@ -825,10 +866,12 @@ def test_ordcycle(): assert d.sched.getCard().ord == 1 assert d.sched.getCard().ord == 2 + def test_counts_idx(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) d.reset() assert d.sched.counts() == (1, 0, 0) @@ -847,10 +890,11 @@ def test_counts_idx(): d.sched.answerCard(c, 1) assert d.sched.counts() == (0, 1, 0) + def test_repCounts(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() # lrnReps should be accurate on pass/fail @@ -868,7 +912,7 @@ def test_repCounts(): d.sched.answerCard(d.sched.getCard(), 3) assert d.sched.counts() == (0, 0, 0) f = d.newNote() - f['Front'] = "two" + f["Front"] = "two" d.addNote(f) d.reset() # initial pass should be correct too @@ -880,14 +924,14 @@ def test_repCounts(): assert d.sched.counts() == (0, 0, 0) # immediate graduate should work f = d.newNote() - f['Front'] = "three" + f["Front"] = "three" d.addNote(f) d.reset() d.sched.answerCard(d.sched.getCard(), 4) assert d.sched.counts() == (0, 0, 0) # and failing a review should too f = d.newNote() - f['Front'] = "three" + f["Front"] = "three" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -899,12 +943,13 @@ def test_repCounts(): d.sched.answerCard(d.sched.getCard(), 1) assert d.sched.counts() == (0, 1, 0) + def test_timing(): d = getEmptyCol() # add a few review cards, due today for i in range(5): f = d.newNote() - f['Front'] = "num"+str(i) + f["Front"] = "num" + str(i) d.addNote(f) c = f.cards()[0] c.type = 2 @@ -925,11 +970,12 @@ def test_timing(): c = d.sched.getCard() assert c.queue == 1 + def test_collapse(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() # test collapsing @@ -939,16 +985,17 @@ def test_collapse(): d.sched.answerCard(c, 4) assert not d.sched.getCard() + def test_deckDue(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # and one that's a child f = d.newNote() - f['Front'] = "two" - default1 = f.model()['did'] = d.decks.id("Default::1") + f["Front"] = "two" + default1 = f.model()["did"] = d.decks.id("Default::1") d.addNote(f) # make it a review card c = f.cards()[0] @@ -957,13 +1004,13 @@ def test_deckDue(): c.flush() # add one more with a new deck f = d.newNote() - f['Front'] = "two" - foobar = f.model()['did'] = d.decks.id("foo::bar") + f["Front"] = "two" + foobar = f.model()["did"] = d.decks.id("foo::bar") d.addNote(f) # and one that's a sibling f = d.newNote() - f['Front'] = "three" - foobaz = f.model()['did'] = d.decks.id("foo::baz") + f["Front"] = "three" + foobaz = f.model()["did"] = d.decks.id("foo::baz") d.addNote(f) d.reset() assert len(d.decks.decks) == 5 @@ -985,10 +1032,12 @@ def test_deckDue(): assert tree[0][5][0][2] == 1 assert tree[0][5][0][4] == 0 # code should not fail if a card has an invalid deck - c.did = 12345; c.flush() + c.did = 12345 + c.flush() d.sched.deckDueList() d.sched.deckDueTree() + def test_deckTree(): d = getEmptyCol() d.decks.id("new::b::c") @@ -998,75 +1047,80 @@ def test_deckTree(): names.remove("new") assert "new" not in names + def test_deckFlow(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) # and one that's a child f = d.newNote() - f['Front'] = "two" - default1 = f.model()['did'] = d.decks.id("Default::2") + f["Front"] = "two" + default1 = f.model()["did"] = d.decks.id("Default::2") d.addNote(f) # and another that's higher up f = d.newNote() - f['Front'] = "three" - default1 = f.model()['did'] = d.decks.id("Default::1") + f["Front"] = "three" + default1 = f.model()["did"] = d.decks.id("Default::1") d.addNote(f) # should get top level one first, then ::1, then ::2 d.reset() - assert d.sched.counts() == (3,0,0) + assert d.sched.counts() == (3, 0, 0) for i in "one", "three", "two": c = d.sched.getCard() - assert c.note()['Front'] == i + assert c.note()["Front"] == i d.sched.answerCard(c, 3) + def test_reorder(): d = getEmptyCol() # add a note with default deck f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) f2 = d.newNote() - f2['Front'] = "two" + f2["Front"] = "two" d.addNote(f2) assert f2.cards()[0].due == 2 - found=False + found = False # 50/50 chance of being reordered for i in range(20): d.sched.randomizeCards(1) if f.cards()[0].due != f.id: - found=True + found = True break assert found d.sched.orderCards(1) assert f.cards()[0].due == 1 # shifting f3 = d.newNote() - f3['Front'] = "three" + f3["Front"] = "three" d.addNote(f3) f4 = d.newNote() - f4['Front'] = "four" + f4["Front"] = "four" d.addNote(f4) assert f.cards()[0].due == 1 assert f2.cards()[0].due == 2 assert f3.cards()[0].due == 3 assert f4.cards()[0].due == 4 - d.sched.sortCards([ - f3.cards()[0].id, f4.cards()[0].id], start=1, shift=True) + d.sched.sortCards([f3.cards()[0].id, f4.cards()[0].id], start=1, shift=True) assert f.cards()[0].due == 3 assert f2.cards()[0].due == 4 assert f3.cards()[0].due == 1 assert f4.cards()[0].due == 2 + def test_forget(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] - c.queue = 2; c.type = 2; c.ivl = 100; c.due = 0 + c.queue = 2 + c.type = 2 + c.ivl = 100 + c.due = 0 c.flush() d.reset() assert d.sched.counts() == (0, 0, 1) @@ -1074,10 +1128,11 @@ def test_forget(): d.reset() assert d.sched.counts() == (1, 0, 0) + def test_resched(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] d.sched.reschedCards([c.id], 0, 0) @@ -1087,14 +1142,15 @@ def test_resched(): assert c.queue == c.type == 2 d.sched.reschedCards([c.id], 1, 1) c.load() - assert c.due == d.sched.today+1 + assert c.due == d.sched.today + 1 assert c.ivl == +1 + def test_norelearn(): d = getEmptyCol() # add a note f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -1108,13 +1164,15 @@ def test_norelearn(): c.flush() d.reset() d.sched.answerCard(c, 1) - d.sched._cardConf(c)['lapse']['delays'] = [] + d.sched._cardConf(c)["lapse"]["delays"] = [] d.sched.answerCard(c, 1) + def test_failmult(): d = getEmptyCol() f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) c = f.cards()[0] c.type = 2 @@ -1126,19 +1184,20 @@ def test_failmult(): c.lapses = 1 c.startTimer() c.flush() - d.sched._cardConf(c)['lapse']['mult'] = 0.5 + d.sched._cardConf(c)["lapse"]["mult"] = 0.5 c = d.sched.getCard() d.sched.answerCard(c, 1) assert c.ivl == 50 d.sched.answerCard(c, 1) assert c.ivl == 25 + def test_moveVersions(): col = getEmptyCol() col.changeSchedulerVer(1) n = col.newNote() - n['Front'] = "one" + n["Front"] = "one" col.addNote(n) # make it a learning card @@ -1176,8 +1235,10 @@ def test_moveVersions(): col.changeSchedulerVer(2) # card with 100 day interval, answering again col.sched.reschedCards([c.id], 100, 100) - c.load(); c.due = 0; c.flush() - col.sched._cardConf(c)['lapse']['mult'] = 0.5 + c.load() + c.due = 0 + c.flush() + col.sched._cardConf(c)["lapse"]["mult"] = 0.5 col.sched.reset() c = col.sched.getCard() col.sched.answerCard(c, 1) @@ -1186,6 +1247,7 @@ def test_moveVersions(): c.load() assert c.due == 50 + # cards with a due date earlier than the collection should retain # their due date when removed def test_negativeDueFilter(): @@ -1193,7 +1255,8 @@ def test_negativeDueFilter(): # card due prior to collection date f = d.newNote() - f['Front'] = "one"; f['Back'] = "two" + f["Front"] = "one" + f["Back"] = "two" d.addNote(f) c = f.cards()[0] c.due = -5 @@ -1209,4 +1272,3 @@ def test_negativeDueFilter(): c.load() assert c.due == -5 - diff --git a/tests/test_stats.py b/tests/test_stats.py index b7212d1e5..587050878 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,12 +1,13 @@ # coding: utf-8 -import os -from tests.shared import getEmptyCol +import os +from tests.shared import getEmptyCol + def test_stats(): d = getEmptyCol() f = d.newNote() - f['Front'] = "foo" + f["Front"] = "foo" d.addNote(f) c = f.cards()[0] # card stats @@ -17,12 +18,15 @@ def test_stats(): d.sched.answerCard(c, 2) assert d.cardStats(c) + def test_graphs_empty(): d = getEmptyCol() assert d.stats().report() + def test_graphs(): from anki import Collection as aopen + d = aopen(os.path.expanduser("~/test.anki2")) g = d.stats() rep = g.report() diff --git a/tests/test_template.py b/tests/test_template.py index 7132c1240..5a61e11f8 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -2,15 +2,18 @@ from anki.template import Template def test_remove_formatting_from_mathjax(): - t = Template('') - assert t._removeFormattingFromMathjax(r'\(2^{{c3::2}}\)', 3) == r'\(2^{{C3::2}}\)' + t = Template("") + assert t._removeFormattingFromMathjax(r"\(2^{{c3::2}}\)", 3) == r"\(2^{{C3::2}}\)" - txt = (r'{{c1::ok}} \(2^2\) {{c2::not ok}} \(2^{{c3::2}}\) \(x^3\) ' - r'{{c4::blah}} {{c5::text with \(x^2\) jax}}') + txt = ( + r"{{c1::ok}} \(2^2\) {{c2::not ok}} \(2^{{c3::2}}\) \(x^3\) " + r"{{c4::blah}} {{c5::text with \(x^2\) jax}}" + ) # Cloze 2 is not in MathJax, so it should not get protected against # formatting. assert t._removeFormattingFromMathjax(txt, 2) == txt - txt = r'\(a\) {{c1::b}} \[ {{c1::c}} \]' + txt = r"\(a\) {{c1::b}} \[ {{c1::c}} \]" assert t._removeFormattingFromMathjax(txt, 1) == ( - r'\(a\) {{c1::b}} \[ {{C1::c}} \]') + r"\(a\) {{c1::b}} \[ {{C1::c}} \]" + ) diff --git a/tests/test_undo.py b/tests/test_undo.py index 457112987..1fdfff8f7 100644 --- a/tests/test_undo.py +++ b/tests/test_undo.py @@ -1,16 +1,17 @@ # coding: utf-8 import time -from tests.shared import getEmptyCol +from tests.shared import getEmptyCol from anki.consts import * + def test_op(): d = getEmptyCol() # should have no undo by default assert not d.undoName() # let's adjust a study option d.save("studyopts") - d.conf['abc'] = 5 + d.conf["abc"] = 5 # it should be listed as undoable assert d.undoName() == "studyopts" # with about 5 minutes until it's clobbered @@ -18,7 +19,7 @@ def test_op(): # undoing should restore the old value d.undo() assert not d.undoName() - assert 'abc' not in d.conf + assert "abc" not in d.conf # an (auto)save will clear the undo d.save("foo") assert d.undoName() == "foo" @@ -27,7 +28,7 @@ def test_op(): # and a review will, too d.save("add") f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() assert d.undoName() == "add" @@ -35,11 +36,12 @@ def test_op(): d.sched.answerCard(c, 2) assert d.undoName() == "Review" + def test_review(): d = getEmptyCol() - d.conf['counts'] = COUNT_REMAINING + d.conf["counts"] = COUNT_REMAINING f = d.newNote() - f['Front'] = "one" + f["Front"] = "one" d.addNote(f) d.reset() assert not d.undoName() @@ -62,7 +64,7 @@ def test_review(): assert not d.undoName() # we should be able to undo multiple answers too f = d.newNote() - f['Front'] = "two" + f["Front"] = "two" d.addNote(f) d.reset() assert d.sched.counts() == (2, 0, 0) @@ -85,5 +87,3 @@ def test_review(): assert d.undoName() == "foo" d.undo() assert not d.undoName() - - diff --git a/tests/test_utils.py b/tests/test_utils.py index 96d787a83..8ac52d4f1 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -2,6 +2,7 @@ from anki.utils import fmtTimeSpan + def test_fmtTimeSpan(): assert fmtTimeSpan(5) == "5 seconds" assert fmtTimeSpan(5, inTime=True) == "in 5 seconds" From ad703f9631d3b7fa6714228aec6088638b0f237f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Wed, 25 Dec 2019 16:05:01 +1000 Subject: [PATCH 37/42] drop oneshell from makefile It's preventing @ prefixes on subsequent lines from being honored. --- Makefile | 1 - 1 file changed, 1 deletion(-) diff --git a/Makefile b/Makefile index 791b9df50..e4affd6fe 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,6 @@ PREFIX := /usr SHELL := bash .SHELLFLAGS := -eu -o pipefail -c -.ONESHELL: .DELETE_ON_ERROR: MAKEFLAGS += --warn-undefined-variables MAKEFLAGS += --no-builtin-rules From ebd07a064e0ffdc469d52a9b4da7906b10ae2507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Pokorn=C3=BD=20=28Rai=29?= Date: Wed, 25 Dec 2019 15:11:22 +0100 Subject: [PATCH 38/42] Use @classmethod on methods that do not depends on instance state --- anki/template/template.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/anki/template/template.py b/anki/template/template.py index f1fc1f882..8194e5720 100644 --- a/anki/template/template.py +++ b/anki/template/template.py @@ -200,12 +200,13 @@ class Template: return "{unknown field %s}" % tag_name return txt - def clozeText(self, txt, ord, type) -> str: + @classmethod + def clozeText(cls, txt, ord, type) -> str: reg = clozeReg if not re.search(reg % ord, txt): # No Cloze deletion was found in txt. return "" - txt = self._removeFormattingFromMathjax(txt, ord) + txt = cls._removeFormattingFromMathjax(txt, ord) def repl(m): # replace chosen cloze with type @@ -225,7 +226,8 @@ class Template: # and display other clozes normally return re.sub(reg % r"\d+", "\\2", txt) - def _removeFormattingFromMathjax(self, txt, ord) -> str: + @classmethod + def _removeFormattingFromMathjax(cls, txt, ord) -> str: """Marks all clozes within MathJax to prevent formatting them. Active Cloze deletions within MathJax should not be wrapped inside From 60438a0219958a5fd3e04d2bc2dcb0225d953d86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michal=20Pokorn=C3=BD=20=28Rai=29?= Date: Wed, 25 Dec 2019 15:34:30 +0100 Subject: [PATCH 39/42] Use dict.setdefault --- anki/find.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/anki/find.py b/anki/find.py index 8cd15654b..bfb4375de 100644 --- a/anki/find.py +++ b/anki/find.py @@ -608,9 +608,7 @@ def findDupes(col, fieldName, search="") -> List[Tuple[Any, List]]: # empty does not count as duplicate if not val: continue - if val not in vals: - vals[val] = [] - vals[val].append(nid) + vals.setdefault(val, []).append(nid) if len(vals[val]) == 2: dupes.append((val, vals[val])) return dupes From 5b831dd9db977ea0d13efba6a71fb0d496d4f7fd Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 26 Dec 2019 06:59:59 +1000 Subject: [PATCH 40/42] update ts lock --- ts/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ts/package-lock.json b/ts/package-lock.json index 3256a1878..0ced64501 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -10,7 +10,7 @@ "integrity": "sha512-Lz4BAJihoFw5nRzKvg4nawXPzutkv7wmfQ5121avptaSIXlDNJCUuxZxX/G+9EVidZGuO0UBlk+YjKbwRKJigg==", "dev": true, "requires": { - "@types/sizzle": "*" + "@types/sizzle": "2.3.2" } }, "@types/jqueryui": { @@ -19,7 +19,7 @@ "integrity": "sha512-bHE7BiG+5Sviy/eA9Npz5HHF3hv40XjaEbpYtSJPaNwuyxhSJ0qWlE8C5DgNMfobVOZ2aSTrM1iGDCGmvlbxOg==", "dev": true, "requires": { - "@types/jquery": "*" + "@types/jquery": "3.3.31" } }, "@types/mathjax": { From 5bb8d317ee2eaa9f93988630a03510a0aead63be Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 26 Dec 2019 07:00:10 +1000 Subject: [PATCH 41/42] don't write test data in ~ --- tests/test_stats.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_stats.py b/tests/test_stats.py index 587050878..193e9b273 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -2,6 +2,7 @@ import os from tests.shared import getEmptyCol +import tempfile def test_stats(): @@ -26,10 +27,11 @@ def test_graphs_empty(): def test_graphs(): from anki import Collection as aopen + dir = tempfile.gettempdir() - d = aopen(os.path.expanduser("~/test.anki2")) + d = aopen(os.path.join(dir, "test.anki2")) g = d.stats() rep = g.report() - with open(os.path.expanduser("~/test.html"), "w") as f: + with open(os.path.join(dir, "test.html"), "w") as f: f.write(rep) return From 3f0b4ff3f6e037c7cc680b27b158ff4eda391fbf Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Thu, 26 Dec 2019 07:36:26 +1000 Subject: [PATCH 42/42] isort tests; black/isort should notice changes to tests --- Makefile | 12 +++++++----- tests/shared.py | 5 ++++- tests/test_addons.py | 3 ++- tests/test_collection.py | 10 +++++----- tests/test_exporting.py | 4 +++- tests/test_importing.py | 11 ++++++----- tests/test_latex.py | 3 +-- tests/test_media.py | 2 +- tests/test_models.py | 6 +++--- tests/test_schedv1.py | 6 +++--- tests/test_schedv2.py | 6 +++--- tests/test_stats.py | 4 +++- tests/test_undo.py | 3 ++- 13 files changed, 43 insertions(+), 32 deletions(-) diff --git a/Makefile b/Makefile index e4affd6fe..ddcf65c08 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ RUNARGS := .SUFFIXES: BLACKARGS := -t py36 anki aqt tests RUSTARGS := --release --strip +ISORTARGS := anki aqt tests $(shell mkdir -p .build) @@ -162,12 +163,13 @@ fix: fix-py-fmt fix-py-imports fix-rs-fmt fix-ts-fmt ###################### PYCHECKDEPS := $(BUILDDEPS) .build/py-check-reqs $(shell find anki aqt -name '*.py' | grep -v buildhash.py) +PYTESTDEPS := $(wildcard tests/*.py) .build/py-mypy: $(PYCHECKDEPS) mypy anki aqt @touch $@ -.build/py-test: $(PYCHECKDEPS) $(wildcard tests/*.py) +.build/py-test: $(PYCHECKDEPS) $(PYTESTDEPS) ./tools/tests.sh @touch $@ @@ -175,11 +177,11 @@ PYCHECKDEPS := $(BUILDDEPS) .build/py-check-reqs $(shell find anki aqt -name '*. pylint -j 0 --rcfile=.pylintrc -f colorized --extension-pkg-whitelist=PyQt5,_ankirs anki aqt @touch $@ -.build/py-imports: $(PYCHECKDEPS) - isort anki aqt --check # if this fails, run 'make fix-py-imports' +.build/py-imports: $(PYCHECKDEPS) $(PYTESTDEPS) + isort $(ISORTARGS) --check # if this fails, run 'make fix-py-imports' @touch $@ -.build/py-fmt: $(PYCHECKDEPS) +.build/py-fmt: $(PYCHECKDEPS) $(PYTESTDEPS) black --check $(BLACKARGS) # if this fails, run 'make fix-py-fmt' @touch $@ @@ -193,7 +195,7 @@ py-fmt: .build/py-fmt .PHONY: fix-py-imports fix-py-fmt fix-py-imports: - isort anki aqt + isort $(ISORTARGS) fix-py-fmt: black $(BLACKARGS) anki aqt diff --git a/tests/shared.py b/tests/shared.py index 1ddf13601..5294a84f0 100644 --- a/tests/shared.py +++ b/tests/shared.py @@ -1,4 +1,7 @@ -import tempfile, os, shutil +import os +import shutil +import tempfile + from anki import Collection as aopen diff --git a/tests/test_addons.py b/tests/test_addons.py index 69bc54245..c5708842c 100644 --- a/tests/test_addons.py +++ b/tests/test_addons.py @@ -1,7 +1,8 @@ import os.path -from mock import MagicMock from tempfile import TemporaryDirectory from zipfile import ZipFile + +from mock import MagicMock from nose2.tools.such import helper from aqt.addons import AddonManager diff --git a/tests/test_collection.py b/tests/test_collection.py index 1868cf8b9..43ce5d058 100644 --- a/tests/test_collection.py +++ b/tests/test_collection.py @@ -1,12 +1,12 @@ # coding: utf-8 -import os, tempfile - -from anki.utils import isWin -from tests.shared import assertException, getEmptyCol -from anki.stdmodels import addBasicModel, models +import os +import tempfile from anki import Collection as aopen +from anki.stdmodels import addBasicModel, models +from anki.utils import isWin +from tests.shared import assertException, getEmptyCol def test_create_open(): diff --git a/tests/test_exporting.py b/tests/test_exporting.py index f02754c3e..dc7db3ff2 100644 --- a/tests/test_exporting.py +++ b/tests/test_exporting.py @@ -1,12 +1,14 @@ # coding: utf-8 -import os, tempfile +import os +import tempfile from nose2.tools.decorators import with_setup from anki import Collection as aopen from anki.exporting import * from anki.importing import Anki2Importer + from .shared import getEmptyCol deck = None diff --git a/tests/test_importing.py b/tests/test_importing.py index fb51a9383..78c1edb2e 100644 --- a/tests/test_importing.py +++ b/tests/test_importing.py @@ -1,15 +1,16 @@ # coding: utf-8 import os -from tests.shared import getUpgradeDeckPath, getEmptyCol -from anki.utils import ids2str + from anki.importing import ( Anki2Importer, - TextImporter, - SupermemoXmlImporter, - MnemosyneImporter, AnkiPackageImporter, + MnemosyneImporter, + SupermemoXmlImporter, + TextImporter, ) +from anki.utils import ids2str +from tests.shared import getEmptyCol, getUpgradeDeckPath testDir = os.path.dirname(__file__) diff --git a/tests/test_latex.py b/tests/test_latex.py index 5bd6c9dae..d8e82b025 100644 --- a/tests/test_latex.py +++ b/tests/test_latex.py @@ -1,11 +1,10 @@ # coding: utf-8 import os - import shutil -from tests.shared import getEmptyCol from anki.utils import stripHTML +from tests.shared import getEmptyCol def test_latex(): diff --git a/tests/test_media.py b/tests/test_media.py index 6f23cd392..89d595468 100644 --- a/tests/test_media.py +++ b/tests/test_media.py @@ -1,7 +1,7 @@ # coding: utf-8 -import tempfile import os +import tempfile import time from .shared import getEmptyCol, testDir diff --git a/tests/test_models.py b/tests/test_models.py index 7deb26b44..f527bf389 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,10 +1,10 @@ # coding: utf-8 import time -from tests.shared import getEmptyCol -from anki.consts import MODEL_CLOZE -from anki.utils import stripHTML, joinFields, isWin import anki.template +from anki.consts import MODEL_CLOZE +from anki.utils import isWin, joinFields, stripHTML +from tests.shared import getEmptyCol def test_modelDelete(): diff --git a/tests/test_schedv1.py b/tests/test_schedv1.py index ad426164d..14b8982f6 100644 --- a/tests/test_schedv1.py +++ b/tests/test_schedv1.py @@ -1,12 +1,12 @@ # coding: utf-8 -import time import copy +import time from anki.consts import STARTING_FACTOR -from tests.shared import getEmptyCol as getEmptyColOrig -from anki.utils import intTime from anki.hooks import addHook +from anki.utils import intTime +from tests.shared import getEmptyCol as getEmptyColOrig def getEmptyCol(): diff --git a/tests/test_schedv2.py b/tests/test_schedv2.py index a1eb012cd..686bd816f 100644 --- a/tests/test_schedv2.py +++ b/tests/test_schedv2.py @@ -1,12 +1,12 @@ # coding: utf-8 -import time import copy +import time from anki.consts import STARTING_FACTOR -from tests.shared import getEmptyCol -from anki.utils import intTime from anki.hooks import addHook +from anki.utils import intTime +from tests.shared import getEmptyCol # Between 2-4AM, shift the time back so test assumptions hold. lt = time.localtime() diff --git a/tests/test_stats.py b/tests/test_stats.py index 193e9b273..7de3dc831 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -1,9 +1,10 @@ # coding: utf-8 import os -from tests.shared import getEmptyCol import tempfile +from tests.shared import getEmptyCol + def test_stats(): d = getEmptyCol() @@ -27,6 +28,7 @@ def test_graphs_empty(): def test_graphs(): from anki import Collection as aopen + dir = tempfile.gettempdir() d = aopen(os.path.join(dir, "test.anki2")) diff --git a/tests/test_undo.py b/tests/test_undo.py index 1fdfff8f7..4b310e171 100644 --- a/tests/test_undo.py +++ b/tests/test_undo.py @@ -1,8 +1,9 @@ # coding: utf-8 import time -from tests.shared import getEmptyCol + from anki.consts import * +from tests.shared import getEmptyCol def test_op():