├── pytest_anki ├── py.typed ├── anki-current.json ├── _errors.py ├── _types.py ├── __init__.py ├── _config.py ├── _qt.py ├── _util.py ├── _addons.py ├── plugin.py ├── _anki.py ├── _patch.py ├── _launch.py └── _session.py ├── .github ├── FUNDING.yml ├── dependabot.yml ├── PULL_REQUEST_TEMPLATE.md ├── actions │ ├── setup_project │ │ └── action.yml │ ├── setup_anki │ │ └── action.yml │ ├── setup │ │ └── action.yml │ └── setup_system │ │ └── action.yml ├── ISSUE_TEMPLATE │ ├── question.md │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── dependabot.yml │ └── general.yml ├── tests ├── samples │ ├── add-ons │ │ ├── simple │ │ │ ├── sample_addon_four │ │ │ │ ├── config.json │ │ │ │ ├── manifest.json │ │ │ │ └── __init__.py │ │ │ ├── sample_addon_three │ │ │ │ ├── manifest.json │ │ │ │ └── __init__.py │ │ │ ├── sample_addon_one.ankiaddon │ │ │ ├── sample_addon_two.ankiaddon │ │ │ └── README.md │ │ └── advanced │ │ │ └── state_checker_addon │ │ │ ├── config.json │ │ │ ├── manifest.json │ │ │ └── __init__.py │ └── decks │ │ └── sample_deck.apkg ├── conftest.py ├── test_web_debugging.py ├── test_fixtures.py └── test_anki_session.py ├── .flake8 ├── CONTRIBUTORS ├── Makefile ├── .gitignore ├── pyproject.toml ├── README.md └── LICENSE /pytest_anki/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | 2 | patreon: glutanimate 3 | ko_fi: glutanimate 4 | -------------------------------------------------------------------------------- /tests/samples/add-ons/simple/sample_addon_four/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": true 3 | } -------------------------------------------------------------------------------- /tests/samples/add-ons/advanced/state_checker_addon/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": null 3 | } -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503 3 | max-line-length = 88 4 | exclude = .git,__pycache__,tests/samples/ -------------------------------------------------------------------------------- /tests/samples/decks/sample_deck.apkg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glutanimate/pytest-anki/HEAD/tests/samples/decks/sample_deck.apkg -------------------------------------------------------------------------------- /tests/samples/add-ons/simple/sample_addon_four/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample_addon_four", 3 | "package": "sample_addon_four" 4 | } -------------------------------------------------------------------------------- /tests/samples/add-ons/simple/sample_addon_three/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sample_addon_three", 3 | "package": "sample_addon_three" 4 | } -------------------------------------------------------------------------------- /tests/samples/add-ons/advanced/state_checker_addon/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "state_checker_addon", 3 | "package": "state_checker_addon" 4 | } -------------------------------------------------------------------------------- /tests/samples/add-ons/simple/sample_addon_four/__init__.py: -------------------------------------------------------------------------------- 1 | from aqt import mw 2 | 3 | mw.sample_addon_four = True 4 | 5 | print("sample_addon_four") 6 | -------------------------------------------------------------------------------- /tests/samples/add-ons/simple/sample_addon_three/__init__.py: -------------------------------------------------------------------------------- 1 | from aqt import mw 2 | 3 | mw.sample_addon_three = True 4 | 5 | print("sample_addon_three") 6 | -------------------------------------------------------------------------------- /pytest_anki/anki-current.json: -------------------------------------------------------------------------------- 1 | { 2 | "anki": "2.1.49", 3 | "python": "3.8.6", 4 | "pyqt": "5.14.2", 5 | "pyqtwebengine": "5.14", 6 | "chrome": "77.0.3865.129" 7 | } -------------------------------------------------------------------------------- /tests/samples/add-ons/simple/sample_addon_one.ankiaddon: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glutanimate/pytest-anki/HEAD/tests/samples/add-ons/simple/sample_addon_one.ankiaddon -------------------------------------------------------------------------------- /tests/samples/add-ons/simple/sample_addon_two.ankiaddon: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/glutanimate/pytest-anki/HEAD/tests/samples/add-ons/simple/sample_addon_two.ankiaddon -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | allow: 8 | - dependency-name: "aqt" 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | #### Description 2 | 3 | *Concisely describe what the pull request is trying to achieve. If pertinent, link to an existing issue report, or briefly explain the problem the PR is meant to solve. Feel free to attach screenshots or other media for UI-related changes.* 4 | -------------------------------------------------------------------------------- /.github/actions/setup_project/action.yml: -------------------------------------------------------------------------------- 1 | name: "Set up project" 2 | description: "Set up project" 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - name: Install Poetry 8 | uses: snok/install-poetry@v1 9 | with: 10 | version: 1.1.8 11 | virtualenvs-create: false 12 | 13 | - name: Set up pytest-anki 14 | shell: bash 15 | run: | 16 | make install 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: Ask a question about this project 4 | title: '' 5 | labels: 'question' 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Checklist 11 | 12 | *Please replace the space inside the brackets with an **x** if the following items apply:* 13 | 14 | - [ ] I've verified that I use the latest version of `pytest-anki` 15 | - [ ] I've checked if anyone else asked this question before by looking through the issue reports. 16 | 17 | 18 | #### Your question 19 | 20 | *A clear and concise question about the project.* 21 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | # The following is a list of people and/or organizations who have contributed 2 | # ideas, code, translations, or in general have helped this project along its way 3 | # in a significant fashion. 4 | # 5 | # This does not necessarily list everyone who has contributed code. 6 | # To see the full list of contributors, please see the revision history 7 | # of this project in the version-control system. 8 | 9 | Ankitects Pty Ltd and contributors 10 | Michal Krassowski 11 | Andrew Sanchez 12 | AMBOSS MD Inc. 13 | BlueGreenMagick 14 | -------------------------------------------------------------------------------- /tests/samples/add-ons/advanced/state_checker_addon/__init__.py: -------------------------------------------------------------------------------- 1 | from copy import deepcopy 2 | 3 | from aqt import gui_hooks, mw 4 | 5 | addon_manager = mw.addonManager 6 | package_name = addon_manager.addonFromModule(__name__) 7 | config = addon_manager.getConfig(package_name) 8 | 9 | # Copy in case the values in question are mutable. We want to make 10 | # sure to record the state at execution time. 11 | meta_storage = deepcopy(mw.pm.meta.get(package_name)) 12 | 13 | profile_storage = None 14 | colconf_storage = None 15 | 16 | 17 | def on_profile_did_open(): 18 | global colconf_storage 19 | global profile_storage 20 | colconf_storage = deepcopy(mw.col.get_config(package_name, default=None)) 21 | profile_storage = deepcopy(mw.pm.profile.get(package_name)) 22 | 23 | 24 | gui_hooks.profile_did_open.append(on_profile_did_open) 25 | -------------------------------------------------------------------------------- /.github/actions/setup_anki/action.yml: -------------------------------------------------------------------------------- 1 | name: "Set up Anki" 2 | description: "Set up Anki" 3 | 4 | inputs: 5 | anki: 6 | description: "Anki version. Set this to 'prerelease' to install either the latest release or prerelease." 7 | required: true 8 | pyqt: 9 | description: "PyQt widget toolkit version" 10 | required: true 11 | pyqtwebengine: 12 | description: "PyQt WebEngine version" 13 | required: true 14 | 15 | runs: 16 | using: "composite" 17 | steps: 18 | - name: Set up Anki ${{ inputs.anki }} 19 | shell: bash 20 | run: | 21 | pip install --upgrade setuptools pip 22 | pip install --upgrade PyQt5==${{ inputs.pyqt }} PyQtWebEngine==${{ inputs.pyqtwebengine }} 23 | 24 | if [[ "${{ inputs.anki }}" == "prerelease" ]]; then 25 | pip install --upgrade --pre anki aqt 26 | else: 27 | pip install --upgrade anki==${{ inputs.anki }} aqt==${{ inputs.anki }} 28 | fi 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Checklist 11 | 12 | *Please replace the space inside the brackets with an **x** if the following items apply:* 13 | 14 | - [ ] I've verified that I use the latest version of `pytest-anki` 15 | - [ ] I've checked if anyone else suggested this feature before by looking through the issue reports. 16 | 17 | #### Problem case 18 | 19 | *Is your feature request related to a problem? If so, please describe it here. E.g.: "My workflow is such and such, and this and that would help."* 20 | 21 | 22 | 23 | #### Solution 24 | 25 | *Concisely describe the solution you would like* 26 | 27 | 28 | *Concisely describe alternatives you've considered (if any)* 29 | 30 | 31 | 32 | #### More information 33 | 34 | *Additional context: Add any other context or screenshots about the feature request here.* 35 | -------------------------------------------------------------------------------- /tests/samples/add-ons/simple/README.md: -------------------------------------------------------------------------------- 1 | ## Specifications for sample add-ons 2 | 3 | ### Packed add-ons 4 | 5 | .ankiaddon-packed add-ons should follow these specifications: 6 | 7 | File name: 8 | 9 | ``` 10 | .ankiaddon 11 | ``` 12 | 13 | Package contents: 14 | 15 | ``` 16 | __init__.py 17 | manifest.json 18 | ``` 19 | 20 | `__init__.py` contents: 21 | 22 | ``` 23 | from aqt import mw 24 | 25 | mw. = True 26 | 27 | print("") 28 | ``` 29 | 30 | `manifest.json` contents: 31 | 32 | ``` 33 | { 34 | "name": "", 35 | "package": "" 36 | } 37 | ``` 38 | 39 | 40 | ### Unpacked add-ons 41 | 42 | Unpacked add-ons follow the same specifications as packed add-ons, with the difference that they are not packaged in an .ankiaddon file, but added in their unpacked form. 43 | 44 | The unpacked folder should follow the following naming: 45 | 46 | ``` 47 | 48 | ``` 49 | 50 | Its contents should be identical to the top-level contents of a packed add-on. -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- 1 | name: "Set up test environment" 2 | description: "Set up test environment" 3 | 4 | inputs: 5 | python: 6 | description: "Python interpreter version" 7 | required: true 8 | anki: 9 | description: "Anki version. Set this to 'prerelease' to install either the latest release or prerelease." 10 | required: true 11 | pyqt: 12 | description: "PyQt widget toolkit version" 13 | required: true 14 | pyqtwebengine: 15 | description: "PyQt WebEngine version" 16 | required: true 17 | chrome: 18 | description: "Chrome version" 19 | required: true 20 | 21 | runs: 22 | using: "composite" 23 | steps: 24 | - name: Checkout pytest-anki 25 | uses: actions/checkout@v2 26 | 27 | - name: Set up environment 28 | uses: ./.github/actions/setup_system 29 | with: 30 | python: ${{ inputs.python }} 31 | chrome: ${{ inputs.chrome }} 32 | 33 | - name: Set up project 34 | uses: ./.github/actions/setup_project 35 | 36 | - name: Set up Anki 37 | uses: ./.github/actions/setup_anki 38 | with: 39 | anki: ${{ inputs.anki }} 40 | pyqt: ${{ inputs.pyqt }} 41 | pyqtwebengine: ${{ inputs.pyqtwebengine }} 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | #### Problem description 11 | 12 | *Please describe the issue concisely in here. In case of an error: Walk us through the steps you took to get there. What happened? What did you expect to happen?* 13 | 14 | 15 | #### Checklist 16 | 17 | *Please replace the space inside the brackets with an **x** if the following items apply:* 18 | 19 | - [ ] I've verified that I use the latest version of `pytest-anki` 20 | - [ ] I've checked if anyone else reported this problem before by looking through the issue reports. I also checked to see if there is a section about known issues in the add-on description, documentation, or README. 21 | 22 | 23 | #### Information about your set-up 24 | 25 | Please run `pip freeze | grep pytest-anki` and paste the output below: 26 | 27 | ``` 28 | 29 | ``` 30 | 31 | *Please fill in details about your operating system and environment:* 32 | 33 | - OS: 34 | - Python version: 35 | - Anki version (commit or tag): 36 | 37 | 38 | #### Error message (if any) 39 | 40 | *If you've received an error message, please copy and paste it between the backticks below:* 41 | 42 | 43 | ```python 44 | 45 | ``` -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /bin/bash 2 | 3 | PACKAGE_FOLDER = pytest_anki 4 | TESTS_FOLDER = tests 5 | MONITORED_FOLDERS = $(PACKAGE_FOLDER) $(TESTS_FOLDER) 6 | TEST_FLAGS ?= -n4 7 | 8 | # Set up project 9 | install: 10 | poetry install 11 | 12 | # Run tests 13 | test: 14 | python -m pytest $(TEST_FLAGS) tests/ 15 | 16 | # Run type checkers 17 | check: 18 | python -m mypy $(MONITORED_FOLDERS) 19 | 20 | # Run code linters 21 | lint: 22 | python -m flake8 $(MONITORED_FOLDERS) 23 | python -m black --check $(MONITORED_FOLDERS) 24 | 25 | # Run code formatters 26 | format: 27 | python -m isort $(MONITORED_FOLDERS) 28 | python -m autoflake --recursive --in-place --remove-all-unused-imports $(MONITORED_FOLDERS) 29 | python -m black $(MONITORED_FOLDERS) 30 | 31 | # Build project 32 | build: 33 | poetry build 34 | 35 | # Show help message 36 | help: 37 | @echo "$$(tput bold)Available targets:$$(tput sgr0)";echo;sed -ne"/^# /{h;s/.*//;:d" -e"H;n;s/^# //;td" -e"s/:.*//;G;s/\\n# /---/;s/\\n/ /g;p;}" ${MAKEFILE_LIST}|LC_ALL='C' sort -f|awk -F --- -v n=$$(tput cols) -v i=19 -v a="$$(tput setaf 6)" -v z="$$(tput sgr0)" '{printf"%s%*s%s ",a,-i,$$1,z;m=split($$2,w," ");l=n-i;for(j=1;j<=m;j++){l-=length(w[j])+1;if(l<= 0){l=n-i-length(w[j])-1;printf"\n%*s ",-i," ";}printf"%s ",w[j];}printf"\n";}' 38 | 39 | .DEFAULT_GOAL: help 40 | .PHONY: install test build check lint format 41 | -------------------------------------------------------------------------------- /.github/actions/setup_system/action.yml: -------------------------------------------------------------------------------- 1 | name: "Set up system" 2 | description: "Set up system" 3 | 4 | inputs: 5 | python: 6 | description: "Python interpreter version" 7 | required: true 8 | chrome: 9 | description: "Chrome version" 10 | required: true 11 | 12 | runs: 13 | using: "composite" 14 | steps: 15 | # Qt5 requires a number of X11-related dependencies to be installed system-wide. 16 | # Rather than installing each individually, we install libqt5 on the machine which 17 | # in turn takes care of installing all deps also needed for the PyPI version of Qt5 18 | - name: Install xvfb / qt dependencies 19 | shell: bash 20 | run: | 21 | sudo apt-get install libqt5gui5 22 | 23 | - name: Set up chromedriver 24 | shell: bash 25 | run: | 26 | chrome_version=${{ inputs.chrome }} 27 | truncated_version=${chrome_version%.*} 28 | driver_version=$(curl --location --fail --retry 10 "http://chromedriver.storage.googleapis.com/LATEST_RELEASE_${truncated_version}") 29 | wget -c -nc --retry-connrefused --tries=0 "https://chromedriver.storage.googleapis.com/${driver_version}/chromedriver_linux64.zip" 30 | unzip -o -q "chromedriver_linux64.zip" 31 | sudo mv chromedriver /usr/local/bin/chromedriver 32 | rm "chromedriver_linux64.zip" 33 | 34 | - name: Set up Python ${{ inputs.python }} 35 | uses: actions/setup-python@v2.2.1 36 | with: 37 | python-version: ${{ inputs.python }} 38 | -------------------------------------------------------------------------------- /pytest_anki/_errors.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | 32 | class AnkiSessionError(Exception): 33 | pass 34 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | 32 | def pytest_collection_modifyitems(items): 33 | for item in items: 34 | item.add_marker("forked") 35 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot tests 2 | 3 | on: 4 | pull_request 5 | 6 | 7 | jobs: 8 | read-current-anki-matrix: 9 | # Reads current Anki set-up from anki-current.json 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | 13 | outputs: 14 | matrix: ${{ steps.set-matrix.outputs.matrix }} 15 | 16 | steps: 17 | - name: Checkout pytest-anki 18 | uses: actions/checkout@v2 19 | - id: set-matrix 20 | run: | 21 | content=$(cat ./pytest_anki/anki-current.json) 22 | content="${content//'%'/'%25'}" 23 | content="${content//$'\n'/'%0A'}" 24 | content="${content//$'\r'/'%0D'}" 25 | content="{ \"include\": [ ${content} ] }" 26 | echo "::set-output name=matrix::$content" 27 | 28 | test-newly-published-anki: 29 | runs-on: ubuntu-latest 30 | if: ${{ github.actor == 'dependabot[bot]' }} 31 | needs: read-current-anki-matrix 32 | 33 | strategy: 34 | matrix: ${{ fromJSON(needs.read-current-anki-matrix.outputs.matrix) }} 35 | 36 | steps: 37 | - name: Checkout pytest-anki 38 | uses: actions/checkout@v2 39 | 40 | - name: Set up environment 41 | uses: ./.github/actions/setup_system 42 | with: 43 | # We assume that these will remain constant across most Anki updates. 44 | # However, sometimes this will break. 45 | python: ${{ matrix.python }} 46 | chrome: ${{ matrix.chrome }} 47 | 48 | - name: Set up project 49 | uses: ./.github/actions/setup_project 50 | 51 | - name: Run tests for Anki 52 | shell: bash 53 | run: | 54 | make test 55 | -------------------------------------------------------------------------------- /pytest_anki/_types.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | 32 | from pathlib import Path 33 | from typing import Tuple, Union 34 | 35 | PathLike = Union[str, Path] 36 | UnpackedAddon = Tuple[PathLike, str] # path to add-on folder, package name 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # dotenv 84 | .env 85 | 86 | # virtualenv 87 | .venv 88 | venv/ 89 | ENV/ 90 | 91 | # Spyder project settings 92 | .spyderproject 93 | .spyproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | anki_bases/ 104 | 105 | 106 | # Other 107 | .directory 108 | .vscode/ 109 | research/ 110 | -------------------------------------------------------------------------------- /pytest_anki/__init__.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | 32 | """ 33 | A simple pytest plugin for testing Anki add-ons 34 | """ 35 | 36 | __all__ = ["AnkiStateUpdate", "AnkiWebViewType", "AnkiSessionError", "AnkiSession"] 37 | 38 | from ._anki import AnkiStateUpdate, AnkiWebViewType # noqa: F401 39 | from ._errors import AnkiSessionError # noqa: F401 40 | from ._session import AnkiSession # noqa: F401 41 | 42 | __version__ = "1.0.0-beta.7" 43 | __author__ = "Aristotelis P. (Glutanimate), Michal Krassowski" 44 | __title__ = "pytest-anki" 45 | __homepage__ = "https://github.com/glutanimate/pytest-anki" 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "pytest-anki" 3 | version = "1.0.0-beta.7" 4 | description = "A pytest plugin for testing Anki add-ons" 5 | authors = ["Aristotelis P. (Glutanimate)", "Michal Krassowski"] 6 | license = "AGPL-3.0-or-later" 7 | repository = "https://github.com/glutanimate/pytest-anki" 8 | homepage = "https://github.com/glutanimate/pytest-anki" 9 | readme = "README.md" 10 | keywords = ["anki", "development", "testing", "test", "pytest"] 11 | classifiers = [ 12 | "Development Status :: 4 - Beta", 13 | "Topic :: Software Development :: Testing", 14 | "Topic :: Software Development :: Quality Assurance", 15 | "Topic :: Software Development :: Testing", 16 | "Topic :: Software Development :: User Interfaces", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.8", 19 | "Programming Language :: Python :: 3.9", 20 | "Framework :: Pytest", 21 | "Intended Audience :: Developers", 22 | ] 23 | include = ["pytest_anki/py.typed", "pytest_anki/anki-current.json"] 24 | 25 | [tool.poetry.plugins.pytest11] 26 | anki = "pytest_anki.plugin" 27 | 28 | [tool.poetry.dependencies] 29 | python = "^3.8" 30 | pytest = ">=3.5.0" 31 | pytest-forked = "^1.3.0" 32 | pytest-xdist = "^2.3.0" 33 | pytest-xvfb = "^2.0.0" 34 | pytest-qt = "^4.0.2" 35 | selenium = "^3.141.0" 36 | packaging = "^21.3" 37 | aqt = ">=2.1.28" 38 | anki = ">=2.1.28" 39 | 40 | [tool.poetry.dev-dependencies] 41 | black = "^21.7b0" 42 | pylint = "^2.10.2" 43 | mypy = "^0.910" 44 | isort = "^5.9.3" 45 | flake8 = "^3.9.2" 46 | requests = "^2.26.0" 47 | types-requests = "^2.25.6" 48 | PyQt5-stubs = "^5.15.2" 49 | autoflake = "^1.4" 50 | 51 | [build-system] 52 | requires = ["poetry-core>=1.0.0"] 53 | build-backend = "poetry.core.masonry.api" 54 | 55 | [tool.pytest.ini_options] 56 | qt_api = "pyqt5" 57 | 58 | [tool.mypy] 59 | show_error_codes = true 60 | ignore_missing_imports = true 61 | follow_imports = "silent" 62 | show_column_numbers = true 63 | exclude = "tests/samples/" 64 | 65 | [tool.black] 66 | exclude = "tests/samples/" 67 | experimental-string-processing = true 68 | 69 | [tool.isort] 70 | multi_line_output = 3 71 | include_trailing_comma = true 72 | line_length=88 73 | ensure_newline_before_comments=true 74 | skip_glob = "tests/samples/**" -------------------------------------------------------------------------------- /pytest_anki/_config.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2022 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | import json 32 | from dataclasses import dataclass 33 | from pathlib import Path 34 | 35 | from packaging.version import Version 36 | 37 | _ANKI_CURRENT_FILE = "anki-current.json" 38 | _ANKI_CURRENT_FILE_PATH = Path(__file__).parent / _ANKI_CURRENT_FILE 39 | 40 | 41 | @dataclass 42 | class LatestTestedLibraryVersions: 43 | anki: Version 44 | python: Version 45 | pyqt: Version 46 | pyqtwebengine: Version 47 | chrome: Version 48 | 49 | 50 | def get_latest_tested_lib_versions() -> LatestTestedLibraryVersions: 51 | with _ANKI_CURRENT_FILE_PATH.open("r", encoding="utf-8") as anki_current_file: 52 | anki_current_data = json.load(anki_current_file) 53 | return LatestTestedLibraryVersions( 54 | **{key: Version(value) for key, value in anki_current_data.items()} 55 | ) 56 | -------------------------------------------------------------------------------- /pytest_anki/_qt.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | 32 | from typing import Any, Callable, Dict, Optional, Tuple 33 | 34 | from PyQt5.QtCore import QMessageLogContext, QObject, QRunnable, QtMsgType, pyqtSignal 35 | 36 | 37 | class QtMessageMatcher(QObject): 38 | 39 | match_found = pyqtSignal() 40 | 41 | def __init__(self, matched_phrase: str): 42 | super().__init__() 43 | self._matched_phrase = matched_phrase 44 | 45 | def __call__(self, type: QtMsgType, context: QMessageLogContext, msg: str): 46 | if self._matched_phrase in msg: 47 | self.match_found.emit() 48 | 49 | 50 | class Signals(QObject): 51 | finished = pyqtSignal() 52 | 53 | 54 | class SignallingWorker(QRunnable): 55 | def __init__( 56 | self, 57 | task: Callable, 58 | task_args: Optional[Tuple[Any, ...]] = None, 59 | task_kwargs: Optional[Dict[str, Any]] = None, 60 | ): 61 | super().__init__() 62 | self.signals = Signals() 63 | self._task = task 64 | self._task_args = task_args or tuple() 65 | self._task_kwargs = task_kwargs or {} 66 | self._result: Optional[Any] = None 67 | self._error: Optional[Exception] = None 68 | 69 | def run(self): 70 | try: 71 | self._result = self._task(*self._task_args, **self._task_kwargs) 72 | except Exception as error: 73 | self._error = error 74 | finally: 75 | self.signals.finished.emit() 76 | 77 | @property 78 | def result(self) -> Optional[Any]: 79 | return self._result 80 | 81 | @property 82 | def error(self) -> Optional[Exception]: 83 | return self._error 84 | -------------------------------------------------------------------------------- /pytest_anki/_util.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | import json 32 | import socket 33 | from contextlib import closing 34 | from functools import reduce 35 | from pathlib import Path 36 | from typing import Any, Union 37 | 38 | 39 | def create_json(path: Union[str, Path], data: dict) -> str: 40 | """Creates a JSON file at the specified path, preloading it with the specified data. 41 | 42 | Arguments: 43 | path {Union[str, Path]} -- path to JSON file 44 | data {dict} -- data to write to file 45 | 46 | Returns: 47 | Path -- path to JSON file 48 | """ 49 | json_path = Path(path) 50 | with json_path.open("w") as f: 51 | f.write(json.dumps(data)) 52 | return str(json_path) 53 | 54 | 55 | def get_nested_attribute(obj: Any, attr: str, *args) -> Any: 56 | """ 57 | Gets nested attribute from "."-separated string 58 | 59 | Arguments: 60 | obj {object} -- object to parse 61 | attr {string} -- attribute name, optionally including 62 | "."-characters to denote different levels 63 | of nesting 64 | 65 | Returns: 66 | Any -- object corresponding to attribute name 67 | 68 | Credits: 69 | https://gist.github.com/wonderbeyond/d293e7a2af1de4873f2d757edd580288 70 | """ 71 | 72 | def _getattr(obj: Any, attr: str): 73 | return getattr(obj, attr, *args) 74 | 75 | return reduce(_getattr, [obj] + attr.split(".")) 76 | 77 | 78 | def find_free_port(): 79 | # https://stackoverflow.com/a/45690594 80 | with closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s: 81 | s.bind(("", 0)) 82 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 83 | return s.getsockname()[1] 84 | -------------------------------------------------------------------------------- /pytest_anki/_addons.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | import shutil 32 | from pathlib import Path 33 | from typing import Any, Dict, NamedTuple, Optional 34 | 35 | from aqt.addons import AddonManager 36 | 37 | from ._types import PathLike 38 | from ._util import create_json 39 | 40 | 41 | def _to_path(path: PathLike) -> Path: 42 | path_obj = Path(path) 43 | if not path_obj.exists(): 44 | raise IOError("Provided path does not exist") 45 | return path_obj 46 | 47 | 48 | def install_addon_from_package(addon_manager: AddonManager, addon_path: PathLike): 49 | addon_path = _to_path(addon_path) 50 | if addon_path.suffix != ".ankiaddon": 51 | raise ValueError("Provided path is not an .ankiaddon file") 52 | addon_manager.install(str(addon_path)) 53 | 54 | 55 | def install_addon_from_folder( 56 | anki_base_dir: PathLike, 57 | package_name: str, 58 | addon_path: PathLike, 59 | ): 60 | addon_path = _to_path(addon_path) 61 | anki_base_dir = _to_path(anki_base_dir) 62 | if not addon_path.is_dir() or not anki_base_dir.is_dir(): 63 | raise ValueError("Provided path is not a folder") 64 | if not package_name: 65 | raise ValueError("Package name must not be empty") 66 | destination_path = anki_base_dir / "addons21" / package_name 67 | shutil.copytree(src=addon_path, dst=destination_path, dirs_exist_ok=True) 68 | 69 | 70 | class ConfigPaths(NamedTuple): 71 | default_config: Optional[Path] 72 | user_config: Optional[Path] 73 | 74 | 75 | def create_addon_config( 76 | anki_base_dir: PathLike, 77 | package_name: str, 78 | default_config: Optional[Dict[str, Any]] = None, 79 | user_config: Optional[Dict[str, Any]] = None, 80 | ) -> ConfigPaths: 81 | addon_path = Path(anki_base_dir) / "addons21" / package_name 82 | addon_path.mkdir(parents=True, exist_ok=True) 83 | 84 | defaults_path = meta_path = None 85 | 86 | if default_config: 87 | defaults_path = addon_path / "config.json" 88 | create_json(defaults_path, default_config) 89 | 90 | if user_config: 91 | meta_path = addon_path / "meta.json" 92 | create_json(meta_path, {"config": user_config}) 93 | 94 | return ConfigPaths(defaults_path, meta_path) 95 | -------------------------------------------------------------------------------- /.github/workflows/general.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**.md" 7 | - "docs/**" 8 | - ".github/dependabot.yml" 9 | push: 10 | branches: 11 | - main 12 | paths-ignore: 13 | - "**.md" 14 | - "docs/**" 15 | - ".github/dependabot.yml" 16 | 17 | jobs: 18 | read-current-anki-matrix: 19 | # Reads current Anki set-up from anki-current.json 20 | runs-on: ubuntu-latest 21 | 22 | outputs: 23 | matrix: ${{ steps.set-matrix.outputs.matrix }} 24 | 25 | steps: 26 | - name: Checkout pytest-anki 27 | uses: actions/checkout@v2 28 | - id: set-matrix 29 | run: | 30 | content=$(cat ./pytest_anki/anki-current.json) 31 | content="${content//'%'/'%25'}" 32 | content="${content//$'\n'/'%0A'}" 33 | content="${content//$'\r'/'%0D'}" 34 | content="{ \"include\": [ ${content} ] }" 35 | echo "::set-output name=matrix::$content" 36 | 37 | check-current-anki: 38 | needs: read-current-anki-matrix 39 | runs-on: ubuntu-18.04 40 | 41 | strategy: 42 | matrix: ${{ fromJSON(needs.read-current-anki-matrix.outputs.matrix) }} 43 | 44 | steps: 45 | - name: Checkout pytest-anki 46 | uses: actions/checkout@v2 47 | 48 | - name: Set up test environment 49 | uses: ./.github/actions/setup 50 | with: 51 | python: ${{ matrix.python }} 52 | chrome: ${{ matrix.chrome }} 53 | anki: ${{ matrix.anki }} 54 | pyqt: ${{ matrix.pyqt }} 55 | pyqtwebengine: ${{ matrix.pyqtwebengine }} 56 | 57 | - name: Run type checker for Anki ${{ matrix.anki }} 58 | shell: bash 59 | run: | 60 | make check 61 | 62 | - name: Run linter for Anki ${{ matrix.anki }} 63 | shell: bash 64 | run: | 65 | make lint 66 | 67 | test-current-anki: 68 | needs: read-current-anki-matrix 69 | runs-on: ubuntu-18.04 70 | 71 | strategy: 72 | matrix: ${{ fromJSON(needs.read-current-anki-matrix.outputs.matrix) }} 73 | 74 | steps: 75 | - name: Checkout pytest-anki 76 | uses: actions/checkout@v2 77 | 78 | - name: Set up test environment 79 | uses: ./.github/actions/setup 80 | with: 81 | python: ${{ matrix.python }} 82 | chrome: ${{ matrix.chrome }} 83 | anki: ${{ matrix.anki }} 84 | pyqt: ${{ matrix.pyqt }} 85 | pyqtwebengine: ${{ matrix.pyqtwebengine }} 86 | 87 | - name: Run tests for Anki ${{ matrix.anki }} 88 | shell: bash 89 | run: | 90 | make test 91 | 92 | test-old-anki: 93 | runs-on: ubuntu-18.04 94 | strategy: 95 | matrix: 96 | include: 97 | # Version matrix follows static macOS builds as closely as possible. 98 | # Some Qt package versions are not available on PyPI, so we need to make 99 | # compromises here and there. 100 | - anki: 2.1.44 101 | python: 3.8.6 102 | pyqt: 5.14.2 103 | pyqtwebengine: 5.14.0 104 | chrome: 77.0.3865.129 105 | - anki: 2.1.35 106 | python: 3.8.0 107 | pyqt: 5.14.2 108 | pyqtwebengine: 5.14.0 109 | chrome: 77.0.3865.129 110 | - anki: 2.1.28 111 | python: 3.8.0 112 | pyqt: 5.15.0 113 | pyqtwebengine: 5.15.0 114 | chrome: 80.0.3987.163 115 | - anki: 2.1.26 116 | python: 3.8.0 117 | pyqt: 5.13.1 118 | pyqtwebengine: 5.13.1 119 | chrome: 73.0.3683.105 120 | 121 | steps: 122 | - name: Checkout pytest-anki 123 | uses: actions/checkout@v2 124 | 125 | - name: Set up test environment 126 | uses: ./.github/actions/setup 127 | with: 128 | python: ${{ matrix.python }} 129 | chrome: ${{ matrix.chrome }} 130 | anki: ${{ matrix.anki }} 131 | pyqt: ${{ matrix.pyqt }} 132 | pyqtwebengine: ${{ matrix.pyqtwebengine }} 133 | 134 | - name: Run tests for Anki ${{ matrix.anki }} 135 | shell: bash 136 | run: | 137 | make test 138 | -------------------------------------------------------------------------------- /tests/test_web_debugging.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | import time 32 | from unittest.mock import Mock 33 | 34 | import pytest 35 | import requests 36 | from pytestqt.qtbot import TimeoutError 37 | from selenium import webdriver 38 | 39 | from pytest_anki import AnkiSession, AnkiWebViewType 40 | 41 | 42 | def test_run_in_thread(anki_session: AnkiSession): 43 | mock_task = Mock() 44 | args = tuple((1, 2, 3)) 45 | kwargs = {"foo": 1, "bar": 2, "zing": 3} 46 | anki_session.run_in_thread_and_wait( 47 | task=mock_task, task_args=args, task_kwargs=kwargs 48 | ) 49 | mock_task.assert_called_once_with(*args, **kwargs) 50 | 51 | 52 | def test_can_supply_timeout(anki_session: AnkiSession): 53 | timeout_duration = 4 54 | task_duration = 3 55 | 56 | def mock_task(): 57 | time.sleep(task_duration) 58 | 59 | start_time = time.time() 60 | try: 61 | anki_session.run_in_thread_and_wait( 62 | task=mock_task, timeout=timeout_duration * 1000 63 | ) 64 | except TimeoutError: 65 | pytest.fail("Call unexpectedly timed out") 66 | 67 | wait_time = time.time() - start_time 68 | 69 | assert timeout_duration > wait_time >= task_duration 70 | 71 | with pytest.raises(TimeoutError): 72 | anki_session.run_in_thread_and_wait( 73 | task=mock_task, timeout=(task_duration - 1) * 1000 74 | ) 75 | 76 | 77 | def test_web_debugging_available_on_launch(anki_session: AnkiSession): 78 | port = anki_session.web_debugging_port 79 | 80 | def assert_web_debugging_interface_up(): 81 | result = requests.get(f"http://127.0.0.1:{port}/") 82 | assert result.status_code == 200 83 | assert "Inspectable pages" in result.text 84 | 85 | anki_session.run_in_thread_and_wait(assert_web_debugging_interface_up) 86 | 87 | 88 | def test_web_driver_can_connect(anki_session: AnkiSession): 89 | def assert_web_driver_connected(driver: webdriver.Chrome): 90 | assert driver.window_handles 91 | 92 | anki_session.run_with_chrome_driver(assert_web_driver_connected) 93 | 94 | 95 | def test_web_driver_can_select_web_view(anki_session: AnkiSession): 96 | def assert_web_driver_connected_to_main_web_view(driver: webdriver.Chrome): 97 | assert driver.title == AnkiWebViewType.main_webview.value 98 | 99 | with anki_session.profile_loaded(): 100 | anki_session.run_with_chrome_driver( 101 | assert_web_driver_connected_to_main_web_view, AnkiWebViewType.main_webview 102 | ) 103 | 104 | 105 | def test_web_driver_can_interact_with_anki(anki_session: AnkiSession): 106 | def switch_to_deck_view(driver: webdriver.Chrome): 107 | driver.find_element_by_xpath("//*[text()='Default']").click() 108 | 109 | with anki_session.profile_loaded(): 110 | assert anki_session.mw.state == "deckBrowser" 111 | anki_session.run_with_chrome_driver( 112 | switch_to_deck_view, AnkiWebViewType.main_webview 113 | ) 114 | 115 | def mw_state_switched(): 116 | assert anki_session.mw.state == "overview" 117 | 118 | anki_session.qtbot.wait_until(mw_state_switched) 119 | -------------------------------------------------------------------------------- /pytest_anki/plugin.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | from typing import TYPE_CHECKING, Any, Dict, Iterator, Optional 32 | 33 | import pytest 34 | 35 | if TYPE_CHECKING: 36 | from pytest import FixtureRequest 37 | from pytestqt.qtbot import QtBot 38 | from _pytest.config import Config # FIXME: not stable 39 | 40 | from ._anki import get_anki_version 41 | from ._config import get_latest_tested_lib_versions 42 | from ._launch import anki_running 43 | from ._session import AnkiSession 44 | 45 | 46 | def pytest_configure(config: "Config"): 47 | """Hook into pytest_configure stage to prepare plugin, e.g. 48 | in order to assert that runtime environment is supported""" 49 | latest_tested_lib_versions = get_latest_tested_lib_versions() 50 | anki_version = get_anki_version() 51 | 52 | if anki_version > (latest_tested_anki_version := latest_tested_lib_versions.anki): 53 | warning = Warning( 54 | "The latest Anki version pytest-anki was tested with is" 55 | f" {latest_tested_anki_version}. You are using {anki_version}. pytest-anki" 56 | " might not behave as expected or not work at all." 57 | ) 58 | config.issue_config_time_warning(warning, stacklevel=2) 59 | 60 | 61 | @pytest.fixture 62 | def anki_session(request: "FixtureRequest", qtbot: "QtBot") -> Iterator[AnkiSession]: 63 | """Fixture that instantiates Anki, yielding an AnkiSession object 64 | 65 | All keyword arguments below may be passed to the fixture by using indirect 66 | parametrization. 67 | 68 | E.g., to specify a custom profile name you would decorate your test method with: 69 | 70 | > @pytest.mark.parametrize("anki_session", [dict(profile_name="foo")], 71 | indirect=True) 72 | 73 | Keyword Arguments: 74 | base_path {str} -- Path to write Anki base folder to 75 | (default: system-wide temporary directory) 76 | 77 | base_name {str} -- Base folder name (default: {"anki_base"}) 78 | 79 | profile_name {str} -- User profile name (default: {"User 1"}) 80 | 81 | lang {str} -- Language to use for the user profile (default: {"en_US"}) 82 | 83 | load_profile {bool} -- Whether to preload Anki user profile (with collection) 84 | (default: {False}) 85 | 86 | preset_anki_state {Optional[pytest_anki.AnkiStateUpdate]}: 87 | Allows pre-configuring Anki object state, as described by a PresetAnkiState 88 | dataclass. This includes the three main configuration storages used by 89 | add-ons, mw.col.conf (colconf_storage), mw.pm.profile (profile_storage), 90 | and mw.pm.meta (meta_storage). 91 | 92 | The provided data is applied on top of the existing data in each case 93 | (i.e. in the same way as dict.update(new_data) would). 94 | 95 | State specified in this manner is guaranteed to be pre-configured ahead of 96 | add-on load time (in the case of meta_storage), or ahead of 97 | gui_hooks.profile_did_open fire time (in the case of colconf_storage and 98 | profile_storage). 99 | 100 | Please note that, in the case of colconf_storage and profile_storage, the 101 | caller is responsible for either passing 'load_profile=True', or manually 102 | loading the profile at a later stage. 103 | 104 | packed_addons {Optional[List[PathLike]]}: List of paths to .ankiaddon-packaged 105 | add-ons that should be installed ahead of starting Anki 106 | 107 | unpacked_addons {Optional[List[Tuple[str, PathLike]]]}: 108 | List of unpacked add-ons that should be installed ahead of starting Anki. 109 | Add-ons need to be specified as tuple of the add-on package name under which 110 | to install the add-on, and the path to the source folder (the package 111 | folder containing the add-on __init__.py) 112 | 113 | addon_configs {Optional[List[Tuple[str, Dict[str, Any]]]]}: 114 | List of add-on package names and config values to set the user configuration 115 | for the specified add-on to. Useful for simulating specific config set-ups. 116 | Each list member needs to be specified as a tuple of add-on package name 117 | and dictionary of user configuration values to set. 118 | 119 | web_debugging_port {Optional[int]}: 120 | If specified, launches Anki with QTWEBENGINE_REMOTE_DEBUGGING set, allowing 121 | you to remotely debug Qt web engine views. 122 | 123 | skip_loading_addons {bool}: 124 | If set to True, will skip loading packed and unpacked add-ons, giving the 125 | caller full control over the add-on import time. 126 | """ 127 | 128 | indirect_parameters: Optional[Dict[str, Any]] = getattr(request, "param", None) 129 | 130 | with anki_running(qtbot=qtbot) if not indirect_parameters else anki_running( 131 | qtbot=qtbot, **indirect_parameters 132 | ) as session: 133 | yield session 134 | -------------------------------------------------------------------------------- /tests/test_fixtures.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | 32 | """ 33 | Tests for the AnkiSession API 34 | """ 35 | 36 | import sys 37 | import tempfile 38 | from pathlib import Path 39 | from typing import Final 40 | 41 | import pytest 42 | 43 | from pytest_anki import AnkiSession, AnkiStateUpdate 44 | 45 | # Indirect parametrization #### 46 | 47 | ANKI_SESSION: Final = "anki_session" 48 | 49 | # Session parameters 50 | 51 | _base_path = str(Path(tempfile.gettempdir()) / "custom_base") 52 | _base_name = "custom_base_name" 53 | _profile_name = "foo" 54 | _lang = "de_DE" 55 | 56 | 57 | @pytest.mark.parametrize( 58 | ANKI_SESSION, 59 | [ 60 | dict( 61 | base_path=_base_path, 62 | base_name=_base_name, 63 | profile_name=_profile_name, 64 | lang=_lang, 65 | ) 66 | ], 67 | indirect=True, 68 | ) 69 | def test_can_set_anki_session_properties(anki_session: AnkiSession): 70 | from anki import lang 71 | 72 | assert anki_session.base.startswith(_base_path) 73 | assert Path(anki_session.base).name.startswith(_base_name) 74 | 75 | with anki_session.profile_loaded(): 76 | assert anki_session.mw.pm.name == _profile_name 77 | assert lang.currentLang == _lang.split("_")[0] 78 | 79 | 80 | # Preloading Anki state 81 | 82 | 83 | @pytest.mark.parametrize(ANKI_SESSION, [dict(load_profile=True)], indirect=True) 84 | def test_can_preload_profile(anki_session: AnkiSession): 85 | from anki.collection import _Collection 86 | 87 | assert anki_session.mw.pm.profile is not None 88 | assert isinstance(anki_session.mw.col, _Collection) 89 | 90 | 91 | # Installing and configuring add-ons 92 | 93 | _addons_path = Path(__file__).parent / "samples" / "add-ons" 94 | _simple_addons_path = _addons_path / "simple" 95 | 96 | _packed_addons = [] 97 | _unpacked_addons = [] 98 | _packages = [] 99 | 100 | for path in _simple_addons_path.iterdir(): 101 | if path.is_dir(): 102 | package_name = path.name 103 | _unpacked_addons.append((package_name, path)) 104 | _packages.append(package_name) 105 | elif path.suffix == ".ankiaddon": 106 | _packed_addons.append(path) 107 | package_name = path.stem 108 | _packages.append(package_name) 109 | 110 | 111 | @pytest.mark.parametrize( 112 | ANKI_SESSION, 113 | [dict(packed_addons=_packed_addons, unpacked_addons=_unpacked_addons)], 114 | indirect=True, 115 | ) 116 | def test_can_install_addons(anki_session: AnkiSession): 117 | main_window = anki_session.mw 118 | all_addons = anki_session.mw.addonManager.allAddons() 119 | 120 | for package in _packages: 121 | assert package in all_addons 122 | assert package in sys.modules 123 | assert getattr(main_window, package) is True 124 | 125 | 126 | _state_checker_addon_package = "state_checker_addon" 127 | _state_checker_addon_path = _addons_path / "advanced" / _state_checker_addon_package 128 | 129 | _unpacked_addons = [] 130 | _addon_configs = [] 131 | 132 | _config_key = "foo" 133 | 134 | for addon_copy in range(2): 135 | package_name = f"{_state_checker_addon_package}_{addon_copy}" 136 | config = {_config_key: addon_copy} 137 | _unpacked_addons.append((package_name, _state_checker_addon_path)) 138 | _addon_configs.append((package_name, config)) 139 | 140 | 141 | @pytest.mark.parametrize( 142 | ANKI_SESSION, 143 | [dict(unpacked_addons=_unpacked_addons, addon_configs=_addon_configs)], 144 | indirect=True, 145 | ) 146 | def test_can_configure_addons(anki_session: AnkiSession): 147 | addon_manager = anki_session.mw.addonManager 148 | for package_name, config in _addon_configs: 149 | addon = __import__(package_name) 150 | assert addon.config == config 151 | assert addon_manager.getConfig(package_name) == config 152 | 153 | 154 | _my_anki_state = AnkiStateUpdate( 155 | meta_storage={_state_checker_addon_package: True}, 156 | profile_storage={_state_checker_addon_package: True}, 157 | colconf_storage={_state_checker_addon_package: True}, 158 | ) 159 | 160 | 161 | @pytest.mark.parametrize( 162 | ANKI_SESSION, 163 | [ 164 | dict( 165 | unpacked_addons=[(_state_checker_addon_package, _state_checker_addon_path)], 166 | preset_anki_state=_my_anki_state, 167 | ) 168 | ], 169 | indirect=True, 170 | ) 171 | def test_can_preset_anki_state(anki_session: AnkiSession): 172 | # We want to assert that the pre-configured state reaches add-ons, so we 173 | # use a sample add-on to record all state at its execution time and 174 | # then run our assertions against that. 175 | package_name = _state_checker_addon_package 176 | 177 | addon = __import__(package_name) 178 | 179 | # assert addon.meta_storage == _my_anki_state.meta_storage 180 | 181 | # Profile and collection are not loaded, yet: 182 | assert addon.profile_storage is None 183 | assert addon.colconf_storage is None 184 | 185 | with anki_session.profile_loaded(): 186 | assert ( 187 | addon.profile_storage 188 | == _my_anki_state.profile_storage[package_name] # type: ignore 189 | ) 190 | assert ( 191 | addon.colconf_storage 192 | == _my_anki_state.colconf_storage[package_name] # type: ignore 193 | ) 194 | -------------------------------------------------------------------------------- /pytest_anki/_anki.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | from dataclasses import dataclass 32 | from enum import Enum 33 | from typing import TYPE_CHECKING, Any, Dict, Optional, Union 34 | 35 | from packaging.version import Version 36 | 37 | from ._errors import AnkiSessionError 38 | from ._util import get_nested_attribute 39 | 40 | if TYPE_CHECKING: 41 | from anki.collection import Collection 42 | from anki.config import ConfigManager 43 | from aqt.main import AnkiQt 44 | 45 | 46 | class AnkiWebViewType(Enum): 47 | main_webview = "main webview" 48 | top_toolbar = "top toolbar" 49 | bottom_toolbar = "bottom toolbar" 50 | legacy_deck_stats = "deck stats" 51 | previewer = "previewer" 52 | browser_card_info = "browser card info" 53 | card_layout = "card layout" 54 | change_notetype = "changeNotetype" 55 | find_duplicates = "find duplicates" 56 | empty_cards = "empty cards" 57 | 58 | 59 | @dataclass 60 | class AnkiStateUpdate: 61 | 62 | """ 63 | Specifies Anki object state to be pre-configured for the test session. 64 | 65 | This includes the three main configuration storages used by add-ons: 66 | 67 | - mw.col.conf (colconf_storage), available at profile load time 68 | - mw.pm.profile (profile_storage), available at profile load time 69 | - mw.pm.meta (meta_storage), available at add-on load time 70 | """ 71 | 72 | colconf_storage: Optional[Dict[str, Any]] = None 73 | profile_storage: Optional[Dict[str, Any]] = None 74 | meta_storage: Optional[Dict[str, Any]] = None 75 | 76 | 77 | class AnkiStorageObject(Enum): 78 | colconf_storage = "col.conf" 79 | profile_storage = "pm.profile" 80 | meta_storage = "pm.meta" 81 | 82 | 83 | def get_collection(main_window: "AnkiQt") -> "Collection": 84 | if (collection := main_window.col) is None: 85 | raise AnkiSessionError( 86 | "Collection has not been loaded, yet. Please use load_profile()." 87 | ) 88 | return collection 89 | 90 | 91 | def update_anki_profile_state( 92 | main_window: "AnkiQt", anki_state_update: AnkiStateUpdate 93 | ): 94 | _update_single_anki_state( 95 | main_window=main_window, 96 | storage_object=AnkiStorageObject.profile_storage, 97 | anki_state_update=anki_state_update, 98 | ) 99 | 100 | 101 | def update_anki_meta_state(main_window: "AnkiQt", anki_state_update: AnkiStateUpdate): 102 | _update_single_anki_state( 103 | main_window=main_window, 104 | storage_object=AnkiStorageObject.meta_storage, 105 | anki_state_update=anki_state_update, 106 | ) 107 | 108 | 109 | def update_anki_colconf_state( 110 | main_window: "AnkiQt", anki_state_update: AnkiStateUpdate 111 | ): 112 | _update_single_anki_state( 113 | main_window=main_window, 114 | storage_object=AnkiStorageObject.colconf_storage, 115 | anki_state_update=anki_state_update, 116 | ) 117 | 118 | 119 | def _update_single_anki_state( 120 | main_window: "AnkiQt", 121 | storage_object: AnkiStorageObject, 122 | anki_state_update: AnkiStateUpdate, 123 | ): 124 | data: Optional[Dict[str, Any]] = getattr(anki_state_update, storage_object.name) 125 | if data: 126 | set_anki_object_data( 127 | main_window=main_window, storage_object=storage_object, data=data 128 | ) 129 | 130 | 131 | def update_anki_state(main_window: "AnkiQt", anki_state_update: AnkiStateUpdate): 132 | if anki_state_update.meta_storage: 133 | update_anki_meta_state( 134 | main_window=main_window, anki_state_update=anki_state_update 135 | ) 136 | 137 | if anki_state_update.profile_storage: 138 | update_anki_profile_state( 139 | main_window=main_window, anki_state_update=anki_state_update 140 | ) 141 | 142 | if anki_state_update.colconf_storage: 143 | update_anki_colconf_state( 144 | main_window=main_window, anki_state_update=anki_state_update 145 | ) 146 | 147 | 148 | def set_anki_object_data( 149 | main_window: "AnkiQt", storage_object: AnkiStorageObject, data: dict 150 | ) -> Union[Dict[str, Any], "ConfigManager"]: 151 | """Update the data of a specified Anki storage object 152 | 153 | This may be used to simulate specific Anki and/or add-on states 154 | during testing.""" 155 | 156 | anki_object = get_anki_object( 157 | main_window=main_window, storage_object=storage_object 158 | ) 159 | 160 | if storage_object == AnkiStorageObject.colconf_storage: 161 | # mw.col.conf dict API is deprecated in favor of ConfigManager API 162 | collection = get_collection(main_window=main_window) 163 | for key, value in data.items(): 164 | collection.set_config(key, value) 165 | else: 166 | anki_object.update(data) # type: ignore 167 | 168 | return anki_object 169 | 170 | 171 | def get_anki_object( 172 | main_window: "AnkiQt", storage_object: AnkiStorageObject 173 | ) -> Union[Dict[str, Any], "ConfigManager"]: 174 | """Get Anki object for specified AnkiStorageObject type""" 175 | attribute_path = storage_object.value 176 | try: 177 | return get_nested_attribute(obj=main_window, attr=attribute_path) 178 | except Exception as e: 179 | raise AnkiSessionError( 180 | f"Anki storage object {storage_object.name} could not be accessed: {str(e)}" 181 | ) 182 | 183 | 184 | def get_anki_version() -> Version: 185 | try: 186 | from anki.buildinfo import version 187 | except (ImportError, ModuleNotFoundError): 188 | from anki import version # type: ignore[attr-defined, no-redef] 189 | return Version(version) 190 | -------------------------------------------------------------------------------- /pytest_anki/_patch.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2017-2021 Ankitects Pty Ltd and contributors 4 | # Copyright (C) 2017-2019 Michal Krassowski 5 | # Copyright (C) 2019-2021 Aristotelis P. 6 | # and contributors (see CONTRIBUTORS file) 7 | # 8 | # This program is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU Affero General Public License as 10 | # published by the Free Software Foundation, either version 3 of the 11 | # License, or (at your option) any later version, with the additions 12 | # listed at the end of the license file that accompanied this program. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU Affero General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU Affero General Public License 20 | # along with this program. If not, see . 21 | # 22 | # NOTE: This program is subject to certain additional terms pursuant to 23 | # Section 7 of the GNU Affero General Public License. You should have 24 | # received a copy of these additional terms immediately following the 25 | # terms and conditions of the GNU Affero General Public License that 26 | # accompanied this program. 27 | # 28 | # If not, please request a copy through one of the means of contact 29 | # listed here: . 30 | # 31 | # Any modifications to this file must keep this entire header intact. 32 | 33 | 34 | import uuid 35 | from argparse import Namespace 36 | from contextlib import contextmanager 37 | from typing import TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Optional, Tuple 38 | from unittest.mock import Mock 39 | 40 | import aqt 41 | from aqt.main import AnkiQt 42 | from aqt.mediasync import MediaSyncer 43 | from aqt.taskman import TaskManager 44 | from PyQt5.QtWidgets import QMainWindow 45 | 46 | if TYPE_CHECKING: 47 | from anki._backend import RustBackend 48 | from anki.collection import Collection 49 | from aqt.profiles import ProfileManager as ProfileManagerType 50 | 51 | from ._addons import ( 52 | create_addon_config, 53 | install_addon_from_folder, 54 | install_addon_from_package, 55 | ) 56 | from ._anki import AnkiStateUpdate, update_anki_meta_state 57 | from ._types import PathLike 58 | 59 | PostUISetupCallbackType = Callable[[AnkiQt], None] 60 | 61 | 62 | def post_ui_setup_callback_factory( 63 | anki_base_dir: PathLike, 64 | packed_addons: Optional[List[PathLike]] = None, 65 | unpacked_addons: Optional[List[Tuple[str, PathLike]]] = None, 66 | addon_configs: Optional[List[Tuple[str, Dict[str, Any]]]] = None, 67 | preset_anki_state: Optional[AnkiStateUpdate] = None, 68 | skip_loading_addons: bool = False, 69 | ): 70 | def post_ui_setup_callback(main_window: AnkiQt): 71 | """Initialize add-on manager, install add-ons, load add-ons""" 72 | main_window.addonManager = aqt.addons.AddonManager(main_window) 73 | 74 | if packed_addons: 75 | for packed_addon in packed_addons: 76 | install_addon_from_package( 77 | addon_manager=main_window.addonManager, addon_path=packed_addon 78 | ) 79 | 80 | if unpacked_addons: 81 | for package_name, addon_path in unpacked_addons: 82 | install_addon_from_folder( 83 | anki_base_dir=anki_base_dir, 84 | package_name=package_name, 85 | addon_path=addon_path, 86 | ) 87 | 88 | if addon_configs: 89 | for package_name, config_values in addon_configs: 90 | create_addon_config( 91 | anki_base_dir=anki_base_dir, 92 | package_name=package_name, 93 | user_config=config_values, 94 | ) 95 | 96 | if preset_anki_state and preset_anki_state.meta_storage: 97 | update_anki_meta_state( 98 | main_window=main_window, anki_state_update=preset_anki_state 99 | ) 100 | 101 | if not skip_loading_addons: 102 | main_window.addonManager.loadAddons() 103 | 104 | return post_ui_setup_callback 105 | 106 | 107 | def custom_init_factory(post_ui_setup_callback: PostUISetupCallbackType): 108 | def custom_init( 109 | main_window: AnkiQt, 110 | app: aqt.AnkiApp, 111 | profileManager: "ProfileManagerType", 112 | backend: "RustBackend", 113 | opts: Namespace, 114 | args: List[Any], 115 | **kwargs, 116 | ): 117 | import aqt 118 | 119 | QMainWindow.__init__(main_window) 120 | main_window.backend = backend 121 | main_window.state = "startup" 122 | main_window.opts = opts 123 | main_window.col: Optional["Collection"] = None # type: ignore 124 | 125 | try: # 2.1.28+ 126 | main_window.taskman = TaskManager(main_window) 127 | except TypeError: 128 | main_window.taskman = TaskManager() # type: ignore 129 | 130 | main_window.media_syncer = MediaSyncer(main_window) 131 | 132 | try: # 2.1.45+ 133 | from aqt.flags import FlagManager 134 | 135 | main_window.flags = FlagManager(main_window) 136 | except (ImportError, ModuleNotFoundError): 137 | pass 138 | 139 | aqt.mw = main_window 140 | main_window.app = app 141 | main_window.pm = profileManager 142 | main_window.safeMode = False # disable safe mode, of no use to us 143 | main_window.setupUI() 144 | 145 | post_ui_setup_callback(main_window) 146 | 147 | try: # 2.1.28+ 148 | main_window.finish_ui_setup() 149 | except AttributeError: 150 | pass 151 | 152 | return custom_init 153 | 154 | 155 | @contextmanager 156 | def patch_anki( 157 | post_ui_setup_callback: PostUISetupCallbackType, 158 | ) -> Iterator[str]: 159 | """Patch Anki to: 160 | - allow more fine-grained control of test execution environment 161 | - enable concurrent testing 162 | - bypass blocking update dialog 163 | """ 164 | from anki.utils import checksum 165 | from aqt import AnkiApp, errors 166 | from aqt.main import AnkiQt 167 | 168 | old_init = AnkiQt.__init__ 169 | old_key = AnkiApp.KEY 170 | old_setupAutoUpdate = AnkiQt.setupAutoUpdate 171 | old_maybe_check_for_addon_updates = AnkiQt.maybe_check_for_addon_updates 172 | old_errorHandler = errors.ErrorHandler 173 | 174 | patched_ankiqt_init = custom_init_factory( 175 | post_ui_setup_callback=post_ui_setup_callback 176 | ) 177 | 178 | AnkiQt.__init__ = patched_ankiqt_init # type: ignore 179 | AnkiApp.KEY = "anki" + checksum(str(uuid.uuid4())) 180 | AnkiQt.setupAutoUpdate = Mock() # type: ignore[assignment] 181 | AnkiQt.maybe_check_for_addon_updates = Mock() # type: ignore[assignment] 182 | errors.ErrorHandler = Mock() # type: ignore[misc] 183 | 184 | yield AnkiApp.KEY 185 | 186 | AnkiQt.__init__ = old_init # type: ignore[assignment] 187 | AnkiApp.KEY = old_key # type: ignore[assignment] 188 | AnkiQt.setupAutoUpdate = old_setupAutoUpdate # type: ignore[assignment] 189 | AnkiQt.maybe_check_for_addon_updates = ( # type: ignore[assignment] 190 | old_maybe_check_for_addon_updates 191 | ) 192 | errors.ErrorHandler = old_errorHandler # type: ignore[misc] 193 | 194 | 195 | def set_qt_message_handler_installer(message_handler_installer: Callable): 196 | aqt.qInstallMessageHandler = message_handler_installer # type: ignore[assignment] 197 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | 3 | pytest-anki is a [pytest](https://docs.pytest.org/) plugin that allows developers to write tests for their [Anki add-ons](https://addon-docs.ankiweb.net/). 4 | 5 | At its core lies the `anki_session` fixture that provides add-on authors with the ability to create and control headless Anki sessions to test their add-ons in: 6 | 7 | ```python 8 | from pytest_anki import AnkiSession 9 | 10 | def test_addon_registers_deck(anki_session: AnkiSession): 11 | my_addon = anki_session.load_addon("my_addon") 12 | with anki_session.load_profile() 13 | with anki_session.deck_installed(deck_path) as deck_id: 14 | assert deck_id in my_addon.deck_ids 15 | 16 | ``` 17 | 18 | `anki_session` comes with a comprehensive API that allows developers to programmatically manipulate Anki, set up and reproduce specific configurations, simulate user interactions, and much more. 19 | 20 | The goal is to provide add-on authors with a one-stop-shop for their functional testing needs, while also enabling them to QA their add-ons against a battery of different Anki versions, catching incompatibilities as they arise. 21 | 22 | ![PyPI](https://img.shields.io/pypi/v/pytest-anki) Code style: black [![tests](https://github.com/glutanimate/pytest-anki/actions/workflows/general.yml/badge.svg)](https://github.com/glutanimate/pytest-anki/actions/workflows/general.yml) 23 | 24 | ## Disclaimer 25 | 26 | ### Project State 27 | 28 | **Important**: The plugin is currently undergoing a major rewrite and expansion of its feature-set, so the documentation below is very sparse at the moment. I am working on bringing the docs up to speed, but until then, please feel free to check out the inline documentation and also take a look at the plug-in's tests for a number of hopefully helpful examples. 29 | 30 | ### Platform Support 31 | 32 | `pytest-anki` has only been confirmed to work on Linux so far. 33 | 34 | 35 | ## Installation 36 | 37 | ### Requirements 38 | 39 | `pytest-anki` requires Python 3.8+. 40 | 41 | ### Installing the latest packaged build 42 | 43 | ```bash 44 | $ pip install pytest-anki 45 | ``` 46 | 47 | or 48 | 49 | ```bash 50 | $ poetry add --dev pytest-anki 51 | ``` 52 | 53 | 54 | ## Usage 55 | 56 | ### Basic Use 57 | 58 | In your tests add: 59 | 60 | ```python 61 | from pytest_anki import AnkiSession # for type checking and completions 62 | 63 | @pytest.mark.forked 64 | def test_my_addon(anki_session: AnkiSession): 65 | # add some tests in here 66 | ``` 67 | 68 | The `anki_session` fixture yields an `AnkiSession` object that gives you access to the following attributes, among others: 69 | 70 | ``` 71 | app {AnkiApp} -- Anki QApplication instance 72 | mw {AnkiQt} -- Anki QMainWindow instance 73 | user {str} -- User profile name (e.g. "User 1") 74 | base {str} -- Path to Anki base directory 75 | ``` 76 | 77 | Additionally, the fixture provides a number of helpful methods and context managers, e.g. for initializing an Anki profile: 78 | 79 | ```python 80 | @pytest.mark.forked 81 | def test_my_addon(anki_session: AnkiSession): 82 | with anki_session.profile_loaded(): 83 | assert anki_session.collection 84 | ``` 85 | 86 | 87 | ### Configuring the Anki Session 88 | 89 | You can customize the Anki session context by passing arguments to the `anki_session` fixture using pytest's indirect parametrization, e.g. 90 | 91 | ```python 92 | import pytest 93 | 94 | @pytest.mark.forked 95 | @pytest.mark.parametrize("anki_session", [dict(load_profile=True)], indirect=True) 96 | def test_my_addon(anki_session: AnkiSession): 97 | # profile / collection already pre-loaded! 98 | assert anki_session.collection 99 | ``` 100 | 101 | ## Additional Notes 102 | 103 | ### When to use pytest-anki 104 | 105 | Running your test in an Anki environment is expensive and introduces an additional layer of confounding factors. If you can `mock` your Anki runtime dependencies away, then that should always be your first tool of choice. 106 | 107 | Where `anki_session` comes in handy is further towards the upper levels of the test pyramid, i.e. functional tests, end-to-end tests, and UI tests. Additionally the plugin can provide you with a convenient way to automate testing for incompatibilities with Anki and other add-ons. 108 | 109 | ### The importance of forking your tests 110 | 111 | You might have noticed that most of the examples above use a `@pytest.mark.forked` decorator. This is because, while the plugin does attempt to tear down Anki sessions as cleanly as possible on exit, this process is never quite perfect, especially for add-ons that monkey-patch Anki. 112 | 113 | With unforked test runs, factors like that can lead to unexpected behavior, or worse still, your tests crashing. Forking a new subprocess for each test bypasses these limitations, and therefore my advice would be to mark any `anki_session` tests as forked by default. 114 | 115 | To do this in batch for an entire test module, you can use the following pytest hook: 116 | 117 | ```python 118 | def pytest_collection_modifyitems(items): 119 | for item in items: 120 | item.add_marker("forked") 121 | ``` 122 | 123 | Future versions of `pytest-anki` will possibly do this by default. 124 | 125 | ### Automated Testing 126 | 127 | `pytest-anki` is designed to work well with continuous integration systems such as GitHub actions. For an example see `pytest-anki`'s own [GitHub workflows](./.github/workflows/). 128 | 129 | 130 | ### Troubleshooting 131 | 132 | #### pytest hanging when using xvfb 133 | 134 | Especially if you run your tests headlessly with `xvfb`, you might run into cases where pytest will sometimes appear to hang. Oftentimes this is due to blocking non-dismissable prompts that your add-on code might invoke in some scenarios. If you suspect that might be the case, my advice would be to temporarily bypass `xvfb` locally via `pytest --no-xvfb` to show the UI and manually debug the issue. 135 | 136 | ## Contributing 137 | 138 | Contributions are welcome! To set up `pytest-anki` for development, please first make sure you have Python 3.8+ and [poetry](https://python-poetry.org/docs/) installed, then run the following steps: 139 | 140 | ``` 141 | $ git clone https://github.com/glutanimate/pytest-anki.git 142 | 143 | $ cd pytest-anki 144 | 145 | # Either set up a new Python virtual environment at this stage 146 | # (e.g. using pyenv), or let poetry create the venv for you 147 | 148 | $ make install 149 | ``` 150 | 151 | Before submitting any changes, please make sure that `pytest-anki`'s checks and tests pass: 152 | 153 | ```bash 154 | make check 155 | make lint 156 | make test 157 | ``` 158 | 159 | This project uses `black`, `isort` and `autoflake` to enforce a consistent code style. To auto-format your code you can use: 160 | 161 | ```bash 162 | make format 163 | ``` 164 | 165 | ## License and Credits 166 | 167 | *pytest-anki* is 168 | 169 | *Copyright © 2019-2021 [Aristotelis P.](https://glutanimate.com/contact/) (Glutanimate) and [contributors](./CONTRIBUTORS)* 170 | 171 | *Copyright © 2017-2019 [Michal Krassowski](https://github.com/krassowski/anki_testing)* 172 | 173 | *Copyright © 2017-2021 [Ankitects Pty Ltd and contributors](https://github.com/ankitects/)* 174 | 175 | 176 | All credits for the original idea for creating a context manager to test Anki add-ons with go to Michal. _pytest-anki_ would not exist without his [anki_testing](https://github.com/krassowski/anki_testing) project. 177 | 178 | I would also like to extend a heartfelt thanks to [AMBOSS](https://github.com/amboss-mededu/) for their major part in supporting the development of this plugin! Most of the recent feature additions leading up to v1.0.0 of the plugin were implemented as part of my work on the [AMBOSS add-on](https://www.amboss.com/us/anki-amboss). 179 | 180 | _pytest-anki_ is free and open-source software. Its source-code is released under the GNU AGPLv3 license, extended by a number of additional terms. For more information please see the [license file](https://github.com/glutanimate/pytest-anki/blob/master/LICENSE) that accompanies this program. 181 | 182 | This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY. Please see the license file for more details. -------------------------------------------------------------------------------- /tests/test_anki_session.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | """ 32 | Tests for all pytest fixtures provided by the plug-in 33 | """ 34 | 35 | import copy 36 | import dataclasses 37 | import json 38 | import sys 39 | from contextlib import contextmanager 40 | from pathlib import Path 41 | from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional 42 | 43 | import pytest 44 | from aqt import AnkiApp 45 | from aqt.main import AnkiQt 46 | 47 | from pytest_anki import AnkiSession, AnkiSessionError, AnkiStateUpdate 48 | 49 | if TYPE_CHECKING: 50 | from pytestqt.qtbot import QtBot 51 | 52 | try: 53 | from anki.collection import Collection 54 | except ImportError: 55 | from anki.collection import _Collection as Collection 56 | 57 | # Helpers 58 | 59 | 60 | @contextmanager 61 | def extend_sys_path(import_path: str) -> Iterator[None]: 62 | sys.path.append(import_path) 63 | yield 64 | sys.path.remove(import_path) 65 | 66 | 67 | # General tests #### 68 | 69 | 70 | def test_anki_session_launches(anki_session: AnkiSession): 71 | assert isinstance(anki_session.app, AnkiApp) 72 | assert isinstance(anki_session.mw, AnkiQt) 73 | assert isinstance(anki_session.user, str) 74 | assert isinstance(anki_session.base, str) 75 | 76 | 77 | # AnkiSession API #### 78 | 79 | 80 | def test_collection_loading_unloading(anki_session: AnkiSession, qtbot: "QtBot"): 81 | try: 82 | from anki.collection import Collection 83 | except ImportError: # <=2.1.26 84 | from anki.collection import _Collection as Collection # type: ignore[no-redef] 85 | 86 | from aqt import gui_hooks 87 | 88 | is_profile_loaded = False 89 | 90 | def on_profile_did_open(): 91 | nonlocal is_profile_loaded 92 | is_profile_loaded = True 93 | 94 | def on_profile_will_close(): 95 | nonlocal is_profile_loaded 96 | is_profile_loaded = False 97 | 98 | gui_hooks.profile_did_open.append(on_profile_did_open) 99 | gui_hooks.profile_will_close.append(on_profile_will_close) 100 | 101 | with pytest.raises(AnkiSessionError) as exception_info: 102 | _ = anki_session.collection 103 | assert "Collection has not been loaded" in str(exception_info.value) 104 | 105 | assert is_profile_loaded is False 106 | 107 | with anki_session.profile_loaded(): 108 | assert isinstance(anki_session.collection, Collection) 109 | assert is_profile_loaded is True 110 | 111 | assert anki_session.mw.col is None 112 | assert is_profile_loaded is False 113 | 114 | collection = anki_session.load_profile() 115 | 116 | assert collection is not None 117 | assert anki_session.mw.col == collection 118 | assert is_profile_loaded is True 119 | 120 | with qtbot.wait_callback() as callback: 121 | anki_session.unload_profile(on_profile_unloaded=callback) 122 | 123 | callback.assert_called_with() 124 | assert is_profile_loaded is False 125 | 126 | 127 | _deck_path = Path(__file__).parent / "samples" / "decks" / "sample_deck.apkg" 128 | 129 | 130 | def _get_deck_ids(collection: "Collection") -> List[int]: 131 | try: 132 | return [int(deck.id) for deck in collection.decks.all_names_and_ids()] 133 | except AttributeError: 134 | return [ 135 | int(deck_id) 136 | for deck_id in collection.decks.allIds() # type: ignore[attr-defined] 137 | ] 138 | 139 | 140 | def _assert_deck_exists(collection: "Collection", deck_id: int): 141 | deck = collection.decks.get(did=deck_id) # type: ignore[arg-type] 142 | assert deck is not None and deck["id"] == deck_id 143 | 144 | 145 | def test_deck_management(anki_session: AnkiSession): 146 | with anki_session.profile_loaded(): 147 | collection = anki_session.collection 148 | assert len(_get_deck_ids(collection)) == 1 149 | 150 | with anki_session.deck_installed(path=_deck_path) as deck_id: 151 | assert len(_get_deck_ids(anki_session.collection)) == 2 152 | _assert_deck_exists(collection=collection, deck_id=deck_id) 153 | 154 | assert len(_get_deck_ids(anki_session.collection)) == 1 155 | 156 | deck_id = anki_session.install_deck(path=_deck_path) 157 | assert len(_get_deck_ids(anki_session.collection)) == 2 158 | _assert_deck_exists(collection=collection, deck_id=deck_id) 159 | 160 | anki_session.remove_deck(deck_id=deck_id) 161 | assert len(_get_deck_ids(anki_session.collection)) == 1 162 | 163 | 164 | @dataclasses.dataclass 165 | class AddonConfig: 166 | package_name: str 167 | default_config: Optional[Dict[str, Any]] = None 168 | user_config: Optional[Dict[str, Any]] = None 169 | 170 | 171 | _addon_configs = [ 172 | AddonConfig( 173 | package_name="sample_addon", 174 | default_config={"foo": True}, 175 | user_config={"foo": False}, 176 | ), 177 | AddonConfig(package_name="32452234234", default_config={"foo": True}), 178 | AddonConfig( 179 | package_name="11211211221", 180 | user_config={"foo": True}, 181 | ), 182 | ] 183 | 184 | 185 | def _assert_config_written(anki_base_dir: str, addon_config: AddonConfig): 186 | addon_path = Path(anki_base_dir) / "addons21" / addon_config.package_name 187 | user_config_path = addon_path / "meta.json" 188 | default_config_path = addon_path / "config.json" 189 | 190 | if addon_config.user_config: 191 | assert user_config_path.exists() 192 | with user_config_path.open() as user_config_file: 193 | user_config = json.load(user_config_file) 194 | assert user_config["config"] == addon_config.user_config 195 | 196 | else: 197 | assert not user_config_path.exists() 198 | 199 | if addon_config.default_config: 200 | assert default_config_path.exists() 201 | with default_config_path.open() as default_config_file: 202 | default_config = json.load(default_config_file) 203 | assert default_config == addon_config.default_config 204 | 205 | else: 206 | assert not default_config_path.exists() 207 | 208 | 209 | _sample_addons_path = Path(__file__).parent / "samples" / "add-ons" / "simple" 210 | _package_name = "sample_addon_three" 211 | 212 | 213 | def test_load_addon(anki_session: AnkiSession): 214 | assert _package_name not in sys.path 215 | 216 | with extend_sys_path(import_path=str(_sample_addons_path)): 217 | addon = anki_session.load_addon(package_name=_package_name) 218 | assert sys.modules[_package_name] == addon 219 | assert getattr(anki_session.mw, _package_name) 220 | 221 | 222 | def test_addon_config_management(anki_session: AnkiSession): 223 | with pytest.raises(ValueError): 224 | anki_session.create_addon_config(package_name="sample_addon") 225 | 226 | for addon_config in _addon_configs: 227 | anki_session.create_addon_config(**dataclasses.asdict(addon_config)) 228 | 229 | _assert_config_written( 230 | anki_base_dir=anki_session.base, addon_config=addon_config 231 | ) 232 | 233 | for addon_config in _addon_configs: 234 | addon_config = copy.deepcopy(addon_config) 235 | addon_config.package_name = addon_config.package_name + "2" 236 | 237 | all_config_paths = [] 238 | 239 | with anki_session.addon_config_created( 240 | **dataclasses.asdict(addon_config) 241 | ) as config_paths: 242 | all_config_paths.append(config_paths) 243 | _assert_config_written( 244 | anki_base_dir=anki_session.base, addon_config=addon_config 245 | ) 246 | 247 | for config_paths in all_config_paths: 248 | if config_paths.default_config: 249 | assert not config_paths.default_config.exists() 250 | if config_paths.user_config: 251 | assert not config_paths.user_config.exists() 252 | 253 | 254 | _anki_state_updates = [ 255 | AnkiStateUpdate(meta_storage={"my_key": True}), 256 | AnkiStateUpdate(profile_storage={"my_key": True}), 257 | AnkiStateUpdate(colconf_storage={"my_key": True}), 258 | AnkiStateUpdate( 259 | meta_storage={"my_key": False}, 260 | profile_storage={"my_key": False}, 261 | colconf_storage={"my_key": False}, 262 | ), 263 | ] 264 | 265 | 266 | def _assert_anki_state_updated( 267 | main_window: "AnkiQt", anki_state_update: AnkiStateUpdate 268 | ): 269 | if anki_state_update.meta_storage: 270 | for key in anki_state_update.meta_storage.keys(): 271 | assert main_window.pm.meta[key] == anki_state_update.meta_storage[key] 272 | 273 | assert main_window.pm.profile is not None 274 | assert main_window.col is not None 275 | 276 | if anki_state_update.profile_storage: 277 | for key in anki_state_update.profile_storage.keys(): 278 | assert main_window.pm.profile[key] == anki_state_update.profile_storage[key] 279 | 280 | if anki_state_update.colconf_storage: 281 | for key in anki_state_update.colconf_storage.keys(): 282 | assert ( 283 | main_window.col.get_config(key) 284 | == anki_state_update.colconf_storage[key] 285 | ) 286 | 287 | 288 | def test_anki_state_updates(anki_session: AnkiSession): 289 | with anki_session.profile_loaded(): 290 | for anki_state_update in _anki_state_updates: 291 | anki_session.update_anki_state(anki_state_update=anki_state_update) 292 | _assert_anki_state_updated( 293 | main_window=anki_session.mw, anki_state_update=anki_state_update 294 | ) 295 | -------------------------------------------------------------------------------- /pytest_anki/_launch.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2017-2021 Ankitects Pty Ltd and contributors 4 | # Copyright (C) 2017-2019 Michal Krassowski 5 | # Copyright (C) 2019-2021 Aristotelis P. 6 | # and contributors (see CONTRIBUTORS file) 7 | # 8 | # This program is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU Affero General Public License as 10 | # published by the Free Software Foundation, either version 3 of the 11 | # License, or (at your option) any later version, with the additions 12 | # listed at the end of the license file that accompanied this program. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU Affero General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU Affero General Public License 20 | # along with this program. If not, see . 21 | # 22 | # NOTE: This program is subject to certain additional terms pursuant to 23 | # Section 7 of the GNU Affero General Public License. You should have 24 | # received a copy of these additional terms immediately following the 25 | # terms and conditions of the GNU Affero General Public License that 26 | # accompanied this program. 27 | # 28 | # If not, please request a copy through one of the means of contact 29 | # listed here: . 30 | # 31 | # Any modifications to this file must keep this entire header intact. 32 | 33 | import os 34 | import shutil 35 | import tempfile 36 | from contextlib import contextmanager, nullcontext 37 | from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Optional, Tuple 38 | from unittest import mock 39 | 40 | from PyQt5.QtCore import qInstallMessageHandler 41 | 42 | from ._anki import AnkiStateUpdate, update_anki_colconf_state, update_anki_profile_state 43 | from ._errors import AnkiSessionError 44 | from ._patch import ( 45 | patch_anki, 46 | post_ui_setup_callback_factory, 47 | set_qt_message_handler_installer, 48 | ) 49 | from ._qt import QtMessageMatcher 50 | from ._session import AnkiSession 51 | from ._types import PathLike 52 | from ._util import find_free_port 53 | 54 | if TYPE_CHECKING: 55 | from pytestqt.qtbot import QtBot 56 | 57 | 58 | QTWEBENGINE_REMOTE_DEBUGGING = "QTWEBENGINE_REMOTE_DEBUGGING" 59 | 60 | 61 | @contextmanager 62 | def temporary_user(anki_base_dir: str, name: str, lang: str) -> Iterator[str]: 63 | 64 | from aqt.profiles import ProfileManager 65 | 66 | pm = ProfileManager(base=anki_base_dir) 67 | 68 | pm.setupMeta() 69 | pm.setLang(lang) 70 | pm.create(name) 71 | 72 | pm.name = name 73 | 74 | yield name 75 | 76 | if pm.db: 77 | # reimplement pm.remove() to avoid trouble with trash 78 | profile_folder = pm.profileFolder() 79 | if os.path.exists(profile_folder): 80 | shutil.rmtree(profile_folder, ignore_errors=True) 81 | pm.db.execute("delete from profiles where name = ?", name) 82 | pm.db.commit() 83 | 84 | 85 | @contextmanager 86 | def base_directory(base_path: str, base_name: str) -> Iterator[str]: 87 | if not os.path.isdir(base_path): 88 | os.mkdir(base_path) 89 | anki_base_dir = tempfile.mkdtemp(prefix=f"{base_name}_", dir=base_path) 90 | yield anki_base_dir 91 | shutil.rmtree(anki_base_dir, ignore_errors=True) 92 | 93 | 94 | @contextmanager 95 | def anki_running( 96 | qtbot: "QtBot", 97 | base_path: str = tempfile.gettempdir(), 98 | base_name: str = "anki_base", 99 | profile_name: str = "User 1", 100 | lang: str = "en_US", 101 | load_profile: bool = False, 102 | preset_anki_state: Optional[AnkiStateUpdate] = None, 103 | packed_addons: Optional[List[PathLike]] = None, 104 | unpacked_addons: Optional[List[Tuple[str, PathLike]]] = None, 105 | addon_configs: Optional[List[Tuple[str, Dict[str, Any]]]] = None, 106 | enable_web_debugging: bool = True, 107 | skip_loading_addons: bool = False, 108 | ) -> Iterator[AnkiSession]: 109 | """Context manager that safely launches an Anki session, cleaning up after itself 110 | 111 | Keyword Arguments: 112 | base_path {str} -- Path to write Anki base folder to 113 | (default: system-wide temporary directory) 114 | 115 | base_name {str} -- Base folder name (default: {"anki_base"}) 116 | 117 | profile_name {str} -- User profile name (default: {"User 1"}) 118 | 119 | lang {str} -- Language to use for the user profile (default: {"en_US"}) 120 | 121 | load_profile {bool} -- Whether to preload Anki user profile (with collection) 122 | (default: {False}) 123 | 124 | preset_anki_state {Optional[pytest_anki.AnkiStateUpdate]}: 125 | Allows pre-configuring Anki object state, as described by a PresetAnkiState 126 | dataclass. This includes the three main configuration storages used by 127 | add-ons, mw.col.conf (colconf_storage), mw.pm.profile (profile_storage), 128 | and mw.pm.meta (meta_storage). 129 | 130 | The provided data is applied on top of the existing data in each case 131 | (i.e. in the same way as dict.update(new_data) would). 132 | 133 | State specified in this manner is guaranteed to be pre-configured ahead of 134 | add-on load time (in the case of meta_storage), or ahead of 135 | gui_hooks.profile_did_open fire time (in the case of colconf_storage and 136 | profile_storage). 137 | 138 | Please note that, in the case of colconf_storage and profile_storage, the 139 | caller is responsible for either passing 'load_profile=True', or manually 140 | loading the profile at a later stage. 141 | 142 | packed_addons {Optional[List[PathLike]]}: List of paths to .ankiaddon-packaged 143 | add-ons that should be installed ahead of starting Anki 144 | 145 | unpacked_addons {Optional[List[Tuple[str, PathLike]]]}: 146 | List of unpacked add-ons that should be installed ahead of starting Anki. 147 | Add-ons need to be specified as tuple of the add-on package name under which 148 | to install the add-on, and the path to the source folder (the package 149 | folder containing the add-on __init__.py) 150 | 151 | addon_configs {Optional[List[Tuple[str, Dict[str, Any]]]]}: 152 | List of add-on package names and config values to set the user configuration 153 | for the specified add-on to. Useful for simulating specific config set-ups. 154 | Each list member needs to be specified as a tuple of add-on package name 155 | and dictionary of user configuration values to set. 156 | 157 | web_debugging_port {Optional[int]}: 158 | If specified, launches Anki with QTWEBENGINE_REMOTE_DEBUGGING set, allowing 159 | you to remotely debug Qt web engine views. 160 | 161 | skip_loading_addons {bool}: 162 | If set to True, will skip loading packed and unpacked add-ons, giving the 163 | caller full control over the add-on import time. 164 | 165 | Returns: 166 | Iterator[AnkiSession] -- [description] 167 | 168 | Yields: 169 | Iterator[AnkiSession] -- [description] 170 | """ 171 | 172 | import aqt 173 | from aqt import gui_hooks 174 | 175 | with base_directory(base_path=base_path, base_name=base_name) as anki_base_dir: 176 | 177 | # Callback to run between main UI initialization and finishing steps of UI 178 | # initialization (add-on loading time) 179 | 180 | post_ui_setup_callback = post_ui_setup_callback_factory( 181 | anki_base_dir=anki_base_dir, 182 | packed_addons=packed_addons, 183 | unpacked_addons=unpacked_addons, 184 | addon_configs=addon_configs, 185 | preset_anki_state=preset_anki_state, 186 | skip_loading_addons=skip_loading_addons, 187 | ) 188 | 189 | # Apply preset Anki profile and collection.conf storage on profile load 190 | 191 | def profile_loaded_callback(): 192 | if not aqt.mw or not preset_anki_state: 193 | return 194 | update_anki_profile_state( 195 | main_window=aqt.mw, anki_state_update=preset_anki_state 196 | ) 197 | update_anki_colconf_state( 198 | main_window=aqt.mw, anki_state_update=preset_anki_state 199 | ) 200 | 201 | if preset_anki_state and ( 202 | preset_anki_state.colconf_storage or preset_anki_state.profile_storage 203 | ): 204 | gui_hooks.profile_did_open.append(profile_loaded_callback) 205 | profile_hooked = True 206 | else: 207 | profile_hooked = False 208 | 209 | # Start Anki session 210 | 211 | with patch_anki(post_ui_setup_callback=post_ui_setup_callback): 212 | with temporary_user( 213 | anki_base_dir=anki_base_dir, name=profile_name, lang=lang 214 | ) as user_name: 215 | 216 | environment = {} 217 | 218 | if enable_web_debugging: 219 | web_debugging_port = find_free_port() 220 | if web_debugging_port is None: 221 | raise OSError("Could not find a free port for remote debugging") 222 | environment[QTWEBENGINE_REMOTE_DEBUGGING] = str(web_debugging_port) 223 | else: 224 | web_debugging_port = None 225 | 226 | with mock.patch.dict(os.environ, environment): 227 | 228 | if os.environ.get(QTWEBENGINE_REMOTE_DEBUGGING): 229 | 230 | # We want to wait until remote debugging started to yield the 231 | # Anki session, so we monitor Qt's log for the corresponding msg 232 | qt_message_matcher = QtMessageMatcher( 233 | "Remote debugging server started successfully" 234 | ) 235 | 236 | # On macOS, Anki does not install a custom message 237 | # handler, so we can install our own directly: 238 | qInstallMessageHandler(qt_message_matcher) 239 | 240 | # On Windows and Linux, we need to monkey-patch the 241 | # message handler installer to make sure that ours is 242 | # not switched out when aqt runs 243 | def install_message_handler(message_handler): 244 | def message_handler_wrapper(*args, **kwargs): 245 | qt_message_matcher(*args, **kwargs) 246 | return message_handler(*args, **kwargs) 247 | 248 | qInstallMessageHandler(message_handler_wrapper) 249 | 250 | set_qt_message_handler_installer(install_message_handler) 251 | 252 | maybe_wait_for_web_debugging = qtbot.wait_signal( 253 | qt_message_matcher.match_found 254 | ) 255 | else: 256 | maybe_wait_for_web_debugging = nullcontext() 257 | 258 | with maybe_wait_for_web_debugging: 259 | # We don't pass in -p in order to avoid 260 | # profileloading. This helps replicate the profile 261 | # availability at add-on init time for most users. Anki 262 | # will automatically open the profile at mw.setupProfile 263 | # time in single-profile setups 264 | app = aqt._run(argv=["anki", "-b", anki_base_dir], exec=False) 265 | 266 | mw = aqt.mw 267 | 268 | if mw is None or app is None: 269 | raise AnkiSessionError("Main window not initialized correctly") 270 | 271 | anki_session = AnkiSession( 272 | app=app, 273 | mw=mw, 274 | user=user_name, 275 | base=anki_base_dir, 276 | qtbot=qtbot, 277 | web_debugging_port=web_debugging_port, 278 | ) 279 | 280 | if not load_profile: 281 | yield anki_session 282 | 283 | else: 284 | with anki_session.profile_loaded(): 285 | yield anki_session 286 | 287 | # Undo monkey-patch if applied 288 | set_qt_message_handler_installer(qInstallMessageHandler) 289 | 290 | # NOTE: clean up does not seem to work properly in all cases, 291 | # so use pytest-forked for now 292 | 293 | # clean up what was spoiled 294 | if aqt.mw: 295 | aqt.mw.cleanupAndExit() 296 | 297 | # remove hooks added by pytest-anki 298 | 299 | if profile_hooked: 300 | gui_hooks.profile_did_open.remove(profile_loaded_callback) 301 | 302 | # remove hooks added during app initialization 303 | from anki import hooks 304 | 305 | hooks._hooks = {} 306 | 307 | # test_nextIvl will fail on some systems if the locales are not restored 308 | import locale 309 | 310 | locale.setlocale(locale.LC_ALL, locale.getdefaultlocale()) # type: ignore 311 | -------------------------------------------------------------------------------- /pytest_anki/_session.py: -------------------------------------------------------------------------------- 1 | # pytest-anki 2 | # 3 | # Copyright (C) 2019-2021 Aristotelis P. 4 | # and contributors (see CONTRIBUTORS file) 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as 8 | # published by the Free Software Foundation, either version 3 of the 9 | # License, or (at your option) any later version, with the additions 10 | # listed at the end of the license file that accompanied this program. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with this program. If not, see . 19 | # 20 | # NOTE: This program is subject to certain additional terms pursuant to 21 | # Section 7 of the GNU Affero General Public License. You should have 22 | # received a copy of these additional terms immediately following the 23 | # terms and conditions of the GNU Affero General Public License that 24 | # accompanied this program. 25 | # 26 | # If not, please request a copy through one of the means of contact 27 | # listed here: . 28 | # 29 | # Any modifications to this file must keep this entire header intact. 30 | 31 | import re 32 | from contextlib import contextmanager 33 | from types import ModuleType 34 | from typing import ( 35 | TYPE_CHECKING, 36 | Any, 37 | Callable, 38 | Dict, 39 | Iterator, 40 | List, 41 | Optional, 42 | Tuple, 43 | Union, 44 | ) 45 | 46 | from anki.importing.apkg import AnkiPackageImporter 47 | from PyQt5.QtCore import QThreadPool, QTimer 48 | from PyQt5.QtWebEngineWidgets import QWebEngineProfile 49 | from selenium import webdriver 50 | 51 | from ._addons import ConfigPaths, create_addon_config 52 | from ._anki import AnkiStateUpdate, AnkiWebViewType, get_collection, update_anki_state 53 | from ._errors import AnkiSessionError 54 | from ._qt import SignallingWorker 55 | from ._types import PathLike 56 | 57 | if TYPE_CHECKING: 58 | from anki.collection import Collection 59 | from aqt import AnkiApp 60 | from aqt.main import AnkiQt 61 | from pytestqt.qtbot import QtBot 62 | 63 | 64 | class AnkiSession: 65 | def __init__( 66 | self, 67 | app: "AnkiApp", 68 | mw: "AnkiQt", 69 | user: str, 70 | base: str, 71 | qtbot: "QtBot", 72 | web_debugging_port: Optional[int] = None, 73 | ): 74 | """Anki test session object, returned by anki_session fixture. 75 | 76 | Contains a number of helpful properties and methods to characterize and 77 | interact with a running Anki test session. 78 | 79 | Arguments: 80 | app {AnkiApp} -- Anki QApplication instance 81 | mw {AnkiQt} -- Anki QMainWindow instance 82 | user {str} -- User profile name (e.g. "User 1") 83 | base {str} -- Path to Anki base directory 84 | """ 85 | 86 | self._app = app 87 | self._mw = mw 88 | self._user = user 89 | self._base = base 90 | self._qtbot = qtbot 91 | self._web_debugging_port = web_debugging_port 92 | self._chrome_driver: Optional[webdriver.Chrome] = None 93 | 94 | # Key session properties #### 95 | 96 | @property 97 | def app(self) -> "AnkiApp": 98 | """Anki's current QApplication instance""" 99 | return self._app 100 | 101 | @property 102 | def mw(self) -> "AnkiQt": 103 | """Anki's current main window instance""" 104 | return self._mw 105 | 106 | @property 107 | def user(self) -> str: 108 | """The current user profile name (e.g. 'User 1')""" 109 | return self._user 110 | 111 | @property 112 | def base(self) -> str: 113 | """Path to Anki base directory""" 114 | return self._base 115 | 116 | # Interaction with Qt 117 | 118 | @property 119 | def qtbot(self) -> "QtBot": 120 | """pytest-qt QtBot fixture""" 121 | return self._qtbot 122 | 123 | # Web view debugging 124 | 125 | @property 126 | def web_debugging_port(self) -> Optional[int]: 127 | """Port used for remote web debugging (if set)""" 128 | return self._web_debugging_port 129 | 130 | @property 131 | def chromium_version(self) -> str: 132 | user_agent = QWebEngineProfile.defaultProfile().httpUserAgent() 133 | match = re.match(r".*Chrome/(.+)\s+.*", user_agent) 134 | if match is None: 135 | raise AnkiSessionError("Could not determine Chromium version") 136 | return match.groups()[0] 137 | 138 | # Collection and profiles #### 139 | 140 | @property 141 | def collection(self) -> "Collection": 142 | """Returns current Anki collection if loaded""" 143 | return get_collection(self._mw) 144 | 145 | def load_profile(self) -> "Collection": 146 | """Load Anki profile, returning user collection 147 | 148 | Note: In a multi-profile configuration this method will raise the profile 149 | selection dialog, blocking until a profile is selected via UI interaction 150 | """ 151 | self._mw.setupProfile() 152 | if self._mw.col is None: 153 | raise AnkiSessionError("Could not load collection") 154 | return self._mw.col 155 | 156 | def unload_profile(self, on_profile_unloaded: Optional[Callable] = None): 157 | """Unload current profile, optionally running a callback when profile 158 | unload complete""" 159 | if on_profile_unloaded is None: 160 | on_profile_unloaded = lambda *args, **kwargs: None # noqa: E731 161 | self._mw.unloadProfile(on_profile_unloaded) 162 | 163 | @contextmanager 164 | def profile_loaded(self) -> Iterator["Collection"]: 165 | """Context manager that takes care of loading and then tearing down 166 | user profile""" 167 | collection = self.load_profile() 168 | 169 | yield collection 170 | 171 | self.unload_profile() 172 | 173 | # Deck management #### 174 | 175 | def install_deck(self, path: PathLike) -> int: 176 | """Install deck from specified .apkg file, returning deck ID""" 177 | old_ids = set(self._get_deck_ids()) 178 | 179 | importer = AnkiPackageImporter(col=self.collection, file=str(path)) 180 | importer.run() 181 | 182 | new_ids = set(self._get_deck_ids()) 183 | 184 | # deck IDs are strings on <=2.1.26 185 | deck_id = int(next(iter(new_ids - old_ids))) 186 | 187 | return deck_id 188 | 189 | def remove_deck(self, deck_id: int): 190 | """Remove deck as specified by provided deck ID""" 191 | try: # 2.1.28+ 192 | # Deck methods on 2.1.45 and up use a DeckId NewType derived from int. 193 | # This only makes a difference at type-check time, so we stick with 194 | # passing in an int for now. 195 | self.collection.decks.remove([deck_id]) # type: ignore[list-item] 196 | except AttributeError: # legacy 197 | self.collection.decks.rem(deck_id, cardsToo=True) 198 | 199 | @contextmanager 200 | def deck_installed(self, path: PathLike) -> Iterator[int]: 201 | """Context manager that takes care of installing deck and then removing 202 | it upon context completion""" 203 | deck_id = self.install_deck(path=path) 204 | 205 | yield deck_id 206 | 207 | self.remove_deck(deck_id=deck_id) 208 | 209 | def _get_deck_ids(self) -> List[int]: 210 | try: # 2.1.28+ 211 | return [d.id for d in self.collection.decks.all_names_and_ids()] 212 | except AttributeError: # legacy 213 | return self.collection.decks.allIds() # type: ignore[attr-defined] 214 | 215 | # Add-on loading #### 216 | 217 | def load_addon(self, package_name: str) -> ModuleType: 218 | """Dynamically import an add-on as specified by its package name""" 219 | addon_package = __import__(package_name) 220 | return addon_package 221 | 222 | # Add-on config handling #### 223 | 224 | def create_addon_config( 225 | self, 226 | package_name: str, 227 | default_config: Optional[Dict[str, Any]] = None, 228 | user_config: Optional[Dict[str, Any]] = None, 229 | ) -> ConfigPaths: 230 | """Create and populate the config.json and meta.json configuration 231 | files for an add-on, as specified by its package name""" 232 | if default_config is None and user_config is None: 233 | raise ValueError( 234 | "Need to provide at least one of default_config, user_config" 235 | ) 236 | 237 | return create_addon_config( 238 | anki_base_dir=self._base, 239 | package_name=package_name, 240 | default_config=default_config, 241 | user_config=user_config, 242 | ) 243 | 244 | @contextmanager 245 | def addon_config_created( 246 | self, 247 | package_name: str, 248 | default_config: Optional[Dict[str, Any]] = None, 249 | user_config: Optional[Dict[str, Any]] = None, 250 | ) -> Iterator[ConfigPaths]: 251 | """Context manager that takes care of creating the configuration files 252 | for an add-on, as specified by its package name, and then deleting them 253 | upon context exit.""" 254 | if default_config is None and user_config is None: 255 | raise ValueError( 256 | "Need to provide at least one of default_config, user_config" 257 | ) 258 | 259 | config_paths = self.create_addon_config( 260 | package_name=package_name, 261 | default_config=default_config, 262 | user_config=user_config, 263 | ) 264 | 265 | yield config_paths 266 | 267 | if config_paths.default_config and config_paths.default_config.exists(): 268 | config_paths.default_config.unlink() 269 | 270 | if config_paths.user_config and config_paths.user_config.exists(): 271 | config_paths.user_config.unlink() 272 | 273 | # Anki config object handling #### 274 | 275 | def update_anki_state(self, anki_state_update: AnkiStateUpdate): 276 | """Set the state of certain Anki storage objects that are frequently used by add-ons. 277 | This includes mw.col.conf (colconf_storage), mw.pm.profile (profile_storage), 278 | and mw.pm.meta (meta_storage). 279 | 280 | The combined state of all objects is supplied as a pytest_anki.AnkiStateUpdate 281 | data class. 282 | """ 283 | update_anki_state(main_window=self._mw, anki_state_update=anki_state_update) 284 | 285 | # Synchronicity / event loop handling #### 286 | 287 | def run_in_thread_and_wait( 288 | self, 289 | task: Callable, 290 | task_args: Optional[Tuple[Any, ...]] = None, 291 | task_kwargs: Optional[Dict[str, Any]] = None, 292 | timeout: int = 5000, 293 | ): 294 | thread_pool = QThreadPool.globalInstance() 295 | worker = SignallingWorker( 296 | task=task, task_args=task_args, task_kwargs=task_kwargs 297 | ) 298 | 299 | with self._qtbot.wait_signal(worker.signals.finished, timeout=timeout): 300 | thread_pool.start(worker) 301 | 302 | if exception := worker.error: 303 | raise exception 304 | 305 | return worker.result 306 | 307 | def set_timeout(self, task: Callable, delay: int, *args, **kwargs): 308 | QTimer.singleShot(delay, lambda: task(*args, **kwargs)) 309 | 310 | # Web debugging #### 311 | 312 | @contextmanager 313 | def _allow_selenium_to_detect_anki(self) -> Iterator[None]: 314 | """ 315 | Context manager that reversibly patches Anki's application name and 316 | version, so that it passes Selenium's logic for identifying 317 | supported browsers 318 | 319 | cf. https://forum.qt.io/topic/96202 320 | """ 321 | old_application_name = self.mw.app.applicationName() 322 | old_application_version = self.mw.app.applicationVersion() 323 | self.mw.app.setApplicationName("Chrome") 324 | self.mw.app.setApplicationVersion(self.chromium_version) 325 | yield 326 | self.mw.app.setApplicationName(old_application_name) 327 | self.mw.app.setApplicationVersion(old_application_version) 328 | 329 | def _switch_chrome_driver_to_web_view( 330 | self, driver: webdriver.Chrome, web_view_title: str 331 | ): 332 | for window_handle in driver.window_handles: 333 | driver.switch_to.window(window_handle) 334 | if driver.title == web_view_title: 335 | break 336 | else: 337 | raise AnkiSessionError( 338 | f"Could not find web view with provided title '{web_view_title}'" 339 | ) 340 | 341 | def run_with_chrome_driver( 342 | self, 343 | test_function: Callable[[webdriver.Chrome], Optional[bool]], 344 | target_web_view: Optional[Union[AnkiWebViewType, str]] = None, 345 | timeout: int = 5000, 346 | ): 347 | """[summary] 348 | 349 | Args: 350 | test_function (Callable[[webdriver.Chrome], Optional[bool]]): [description] 351 | target_web_view: Web view as identified by its title. Defaults to None. 352 | timeout: Time to wait for task to complete until qtbot raises a TimeoutError 353 | """ 354 | if self._web_debugging_port is None: 355 | raise AnkiSessionError("Web debugging interface is not active") 356 | 357 | web_view_title: Optional[str] 358 | 359 | if isinstance(target_web_view, AnkiWebViewType): 360 | web_view_title = target_web_view.value 361 | else: 362 | web_view_title = target_web_view 363 | 364 | def test_wrapper() -> Optional[bool]: 365 | if not self._chrome_driver: 366 | options = webdriver.ChromeOptions() 367 | options.add_experimental_option( 368 | "debuggerAddress", f"127.0.0.1:{self._web_debugging_port}" 369 | ) 370 | self._chrome_driver = webdriver.Chrome(options=options) 371 | 372 | if web_view_title: 373 | self._switch_chrome_driver_to_web_view( 374 | driver=self._chrome_driver, web_view_title=web_view_title 375 | ) 376 | 377 | return test_function(self._chrome_driver) 378 | 379 | with self._allow_selenium_to_detect_anki(): 380 | return self.run_in_thread_and_wait(test_wrapper, timeout=timeout) 381 | 382 | def reset_chrome_driver(self): 383 | if not self._chrome_driver: 384 | return 385 | self._chrome_driver.quit() 386 | self._chrome_driver = None 387 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This Program is licensed under the GNU Affero General Public License 2 | version 3, extended by a number of additional terms that follow at the 3 | very bottom of the AGPLv3 license text. 4 | 5 | If not otherwise noted, this license applies to all source code items 6 | included with this Program. Code and other resources that are subject 7 | to different licensing terms might also ship with this Program, but 8 | will be clearly marked as such in additional LICENSE* files. 9 | 10 | The AGPLv3 license and additional terms follow. 11 | 12 | 13 | GNU AFFERO GENERAL PUBLIC LICENSE 14 | Version 3, 19 November 2007 15 | 16 | Copyright (C) 2007 Free Software Foundation, Inc. 17 | Everyone is permitted to copy and distribute verbatim copies 18 | of this license document, but changing it is not allowed. 19 | 20 | Preamble 21 | 22 | The GNU Affero General Public License is a free, copyleft license for 23 | software and other kinds of works, specifically designed to ensure 24 | cooperation with the community in the case of network server software. 25 | 26 | The licenses for most software and other practical works are designed 27 | to take away your freedom to share and change the works. By contrast, 28 | our General Public Licenses are intended to guarantee your freedom to 29 | share and change all versions of a program--to make sure it remains free 30 | software for all its users. 31 | 32 | When we speak of free software, we are referring to freedom, not 33 | price. Our General Public Licenses are designed to make sure that you 34 | have the freedom to distribute copies of free software (and charge for 35 | them if you wish), that you receive source code or can get it if you 36 | want it, that you can change the software or use pieces of it in new 37 | free programs, and that you know you can do these things. 38 | 39 | Developers that use our General Public Licenses protect your rights 40 | with two steps: (1) assert copyright on the software, and (2) offer 41 | you this License which gives you legal permission to copy, distribute 42 | and/or modify the software. 43 | 44 | A secondary benefit of defending all users' freedom is that 45 | improvements made in alternate versions of the program, if they 46 | receive widespread use, become available for other developers to 47 | incorporate. Many developers of free software are heartened and 48 | encouraged by the resulting cooperation. However, in the case of 49 | software used on network servers, this result may fail to come about. 50 | The GNU General Public License permits making a modified version and 51 | letting the public access it on a server without ever releasing its 52 | source code to the public. 53 | 54 | The GNU Affero General Public License is designed specifically to 55 | ensure that, in such cases, the modified source code becomes available 56 | to the community. It requires the operator of a network server to 57 | provide the source code of the modified version running there to the 58 | users of that server. Therefore, public use of a modified version, on 59 | a publicly accessible server, gives the public access to the source 60 | code of the modified version. 61 | 62 | An older license, called the Affero General Public License and 63 | published by Affero, was designed to accomplish similar goals. This is 64 | a different license, not a version of the Affero GPL, but Affero has 65 | released a new version of the Affero GPL which permits relicensing under 66 | this license. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU Affero General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Remote Network Interaction; Use with the GNU General Public License. 553 | 554 | Notwithstanding any other provision of this License, if you modify the 555 | Program, your modified version must prominently offer all users 556 | interacting with it remotely through a computer network (if your version 557 | supports such interaction) an opportunity to receive the Corresponding 558 | Source of your version by providing access to the Corresponding Source 559 | from a network server at no charge, through some standard or customary 560 | means of facilitating copying of software. This Corresponding Source 561 | shall include the Corresponding Source for any work covered by version 3 562 | of the GNU General Public License that is incorporated pursuant to the 563 | following paragraph. 564 | 565 | Notwithstanding any other provision of this License, you have 566 | permission to link or combine any covered work with a work licensed 567 | under version 3 of the GNU General Public License into a single 568 | combined work, and to convey the resulting work. The terms of this 569 | License will continue to apply to the part which is the covered work, 570 | but the work with which it is combined will remain governed by version 571 | 3 of the GNU General Public License. 572 | 573 | 14. Revised Versions of this License. 574 | 575 | The Free Software Foundation may publish revised and/or new versions of 576 | the GNU Affero General Public License from time to time. Such new versions 577 | will be similar in spirit to the present version, but may differ in detail to 578 | address new problems or concerns. 579 | 580 | Each version is given a distinguishing version number. If the 581 | Program specifies that a certain numbered version of the GNU Affero General 582 | Public License "or any later version" applies to it, you have the 583 | option of following the terms and conditions either of that numbered 584 | version or of any later version published by the Free Software 585 | Foundation. If the Program does not specify a version number of the 586 | GNU Affero General Public License, you may choose any version ever published 587 | by the Free Software Foundation. 588 | 589 | If the Program specifies that a proxy can decide which future 590 | versions of the GNU Affero General Public License can be used, that proxy's 591 | public statement of acceptance of a version permanently authorizes you 592 | to choose that version for the Program. 593 | 594 | Later license versions may give you additional or different 595 | permissions. However, no additional obligations are imposed on any 596 | author or copyright holder as a result of your choosing to follow a 597 | later version. 598 | 599 | 15. Disclaimer of Warranty. 600 | 601 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 602 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 603 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 604 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 605 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 606 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 607 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 608 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 609 | 610 | 16. Limitation of Liability. 611 | 612 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 613 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 614 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 615 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 616 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 617 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 618 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 619 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 620 | SUCH DAMAGES. 621 | 622 | 17. Interpretation of Sections 15 and 16. 623 | 624 | If the disclaimer of warranty and limitation of liability provided 625 | above cannot be given local legal effect according to their terms, 626 | reviewing courts shall apply local law that most closely approximates 627 | an absolute waiver of all civil liability in connection with the 628 | Program, unless a warranty or assumption of liability accompanies a 629 | copy of the Program in return for a fee. 630 | 631 | END OF TERMS AND CONDITIONS 632 | 633 | =============================================================================== 634 | 635 | ADDITIONAL TERMS APPLICABLE TO THIS PROGRAM 636 | UNDER GNU AGPL VERSION 3 SECTION 7 637 | 638 | The following additional terms ("Additional Terms") supplement and modify the 639 | GNU Affero General Public License, Version 3 ("AGPL") applicable to the present 640 | Program. 641 | 642 | In addition to the terms and conditions of the AGPL, the present Program is 643 | subject to the further restrictions below: 644 | 645 | 1. Trademark and Publicity Rights. 646 | 647 | Except as expressly provided herein, no trademark or publicity rights are 648 | granted. This license does NOT give you any right, title or interest in the 649 | "Glutanimate" name or logo. 650 | 651 | 2. Origin of the Program. 652 | 653 | The origin of the Program must not be misrepresented; you must not claim 654 | that you wrote the original Program. Altered source versions must be plainly 655 | marked as such, and must not be misrepresented as being the original 656 | Program. 657 | 658 | 3. Legal Notices and Author Attributions. 659 | 660 | You must reproduce faithfully all trademark, copyright and other proprietary 661 | and legal notices on any copies of the Program or any other required author 662 | attributions. Legal notices or author attributions displayed as part of the 663 | user interface must be preserved as such. 664 | 665 | 4. Use of Names of Licensors or Authors for Publicity Purposes. 666 | 667 | Outside of the aforementioned legal notices and author attributions, neither 668 | the name of the copyright holder or its affiliates, any other party who 669 | modifies and/or conveys the Program, nor the names of the Program's 670 | sponsors/supporters/patrons may be used to endorse or promote products 671 | derived from this software without specific prior written permission. 672 | 673 | 5. Indemnification. 674 | 675 | IF YOU CONVEY A COVERED WORK AND AGREE WITH ANY RECIPIENT OF THAT COVERED 676 | WORK THAT YOU WILL ASSUME ANY LIABILITY FOR THAT COVERED WORK, YOU HEREBY 677 | AGREE TO INDEMNIFY, DEFEND AND HOLD HARMLESS THE OTHER LICENSORS AND AUTHORS 678 | OF THAT COVERED WORK FOR ANY DAMAGES, DEMANDS, CLAIMS, LOSSES, CAUSES OF 679 | ACTION, LAWSUITS, JUDGMENTS EXPENSES (INCLUDING WITHOUT LIMITATION 680 | REASONABLE ATTORNEYS' FEES AND EXPENSES) OR ANY OTHER LIABLITY ARISING FROM, 681 | RELATED TO OR IN CONNECTION WITH YOUR ASSUMPTIONS OF LIABILITY. 682 | 683 | 6. Preservation of Licensing Terms. 684 | 685 | Any covered work conveyed by you must include this license text in its 686 | entirety. 687 | 688 | ------------------------------------------------------------------------------- 689 | 690 | If you have any questions regarding this license, about any other legal 691 | details, or want to report an infringement of the aforementioned licensing 692 | terms, please feel free to contact me at: 693 | --------------------------------------------------------------------------------