pax_global_header00006660000000000000000000000064146724115070014521gustar00rootroot0000000000000052 comment=bd82acf4042d34c523218c64719f714a2aa53eb9 app-model-0.3.0/000077500000000000000000000000001467241150700133775ustar00rootroot00000000000000app-model-0.3.0/.github/000077500000000000000000000000001467241150700147375ustar00rootroot00000000000000app-model-0.3.0/.github/ISSUE_TEMPLATE.md000066400000000000000000000005001467241150700174370ustar00rootroot00000000000000* app-model version: * Python version: * Operating System: ### Description Describe what you were trying to get done. Tell us what happened, what went wrong, and what you expected to happen. ### What I Did ``` Paste the command(s) you ran and the output. If there was a crash, please include the traceback here. ``` app-model-0.3.0/.github/TEST_FAIL_TEMPLATE.md000066400000000000000000000006111467241150700201240ustar00rootroot00000000000000--- title: "{{ env.TITLE }}" labels: [bug] --- The {{ workflow }} workflow failed on {{ date | date("YYYY-MM-DD HH:mm") }} UTC The most recent failing test was on {{ env.PLATFORM }} py{{ env.PYTHON }} with commit: {{ sha }} Full run: https://github.com/pyapp-kit/app-model/actions/runs/{{ env.RUN_ID }} (This post will be updated if another test fails, as long as this issue remains open.) app-model-0.3.0/.github/dependabot.yml000066400000000000000000000004241467241150700175670ustar00rootroot00000000000000# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: "weekly" commit-message: prefix: "ci(dependabot):" app-model-0.3.0/.github/workflows/000077500000000000000000000000001467241150700167745ustar00rootroot00000000000000app-model-0.3.0/.github/workflows/ci.yml000066400000000000000000000103771467241150700201220ustar00rootroot00000000000000name: CI on: push: branches: [main] tags: [v*] pull_request: workflow_dispatch: schedule: - cron: "0 0 * * *" # run once a day concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 with: os: ${{ matrix.os }} python-version: ${{ matrix.python-version }} pip-post-installs: ${{ matrix.pydantic }} pip-install-pre-release: ${{ github.event_name == 'schedule' }} coverage-upload: artifact strategy: fail-fast: false matrix: python-version: ["3.8", "3.10", "3.12"] os: [ubuntu-latest, macos-latest, windows-latest] pydantic: [""] exclude: - python-version: "3.8" os: "macos-latest" include: - python-version: "3.8" os: "macos-13" - python-version: "3.9" os: "macos-13" - python-version: "3.11" os: "ubuntu-latest" - python-version: "3.12" os: "ubuntu-latest" pydantic: "'pydantic<2'" - python-version: "3.8" os: "ubuntu-latest" pydantic: "'pydantic<2'" test-qt: uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 with: qt: ${{ matrix.qt }} os: ${{ matrix.os }} python-version: ${{ matrix.python-version }} extras: test-qt pip-install-pre-release: ${{ github.event_name == 'schedule' }} coverage-upload: artifact strategy: fail-fast: false matrix: include: - python-version: "3.8" os: "ubuntu-latest" qt: "PyQt5==5.12" - python-version: "3.10" os: "ubuntu-latest" qt: "PyQt5~=5.15.0" - python-version: "3.10" os: "ubuntu-latest" qt: "PySide2~=5.15.0" - python-version: "3.10" os: "ubuntu-latest" qt: "PyQt6~=6.2.0" - python-version: "3.10" os: "ubuntu-latest" qt: "PySide6~=6.3.0" - python-version: "3.10" os: "ubuntu-latest" qt: "PyQt6~=6.4.0" - python-version: "3.11" os: "ubuntu-latest" qt: "PySide6~=6.5.0" - python-version: "3.11" os: "ubuntu-latest" qt: "PySide6~=6.6.0" - python-version: "3.11" os: "ubuntu-latest" qt: pyqt6 - python-version: "3.10" os: "windows-latest" qt: "PySide2" - python-version: "3.9" os: "macos-13" qt: "PySide2" upload_coverage: if: always() needs: [test, test-qt] uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2 secrets: inherit test_napari: uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2 with: dependency-repo: napari/napari dependency-ref: ${{ matrix.napari-version }} dependency-extras: "testing" qt: ${{ matrix.qt }} pytest-args: 'napari/_qt/_qapp_model napari/_app_model napari/utils/_tests/test_key_bindings.py -k "not async and not qt_dims_2"' python-version: "3.10" post-install-cmd: "pip install lxml_html_clean" # fix for napari v0.4.19 strategy: fail-fast: false matrix: napari-version: ["", "v0.4.19.post1"] qt: ["pyqt5", "pyside2"] check-manifest: name: Check Manifest runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: pipx run check-manifest deploy: name: Deploy needs: [check-manifest, test, test_napari] if: success() && startsWith(github.ref, 'refs/tags/') runs-on: ubuntu-latest permissions: id-token: write contents: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.x" - name: 👷 Build run: | python -m pip install build python -m build - name: 🚢 Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - uses: softprops/action-gh-release@v2 with: generate_release_notes: true files: "./dist/*" app-model-0.3.0/.github_changelog_generator000066400000000000000000000004211467241150700207340ustar00rootroot00000000000000user=pyapp-kit project=app-model issues=false exclude-labels=duplicate,question,invalid,wontfix,hide add-sections={"tests":{"prefix":"**Tests & CI:**","labels":["tests"]}, "documentation":{"prefix":"**Documentation:**", "labels":["documentation"]}} exclude-tags-regex=.*rc app-model-0.3.0/.gitignore000066400000000000000000000023361467241150700153730ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # IDE settings .vscode/ app_model/_version.py src/app_model/_version.py app-model-0.3.0/.pre-commit-config.yaml000066400000000000000000000014201467241150700176550ustar00rootroot00000000000000ci: autoupdate_schedule: monthly autofix_commit_msg: "style: [pre-commit.ci] auto fixes [...]" autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate" repos: - repo: https://github.com/crate-ci/typos rev: v1.24.5 hooks: - id: typos args: [] - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.6.4 hooks: - id: ruff args: ["--fix", "--unsafe-fixes"] - id: ruff-format - repo: https://github.com/abravalheri/validate-pyproject rev: v0.19 hooks: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.11.2 hooks: - id: mypy files: "^src/" additional_dependencies: - pydantic >2 - pydantic-compat - in-n-out app-model-0.3.0/.readthedocs.yaml000066400000000000000000000004501467241150700166250ustar00rootroot00000000000000# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-22.04 tools: python: "3.10" mkdocs: configuration: mkdocs.yml fail_on_warning: true python: install: - method: pip path: . extra_requirements: - docs app-model-0.3.0/CHANGELOG.md000066400000000000000000000514561467241150700152230ustar00rootroot00000000000000# Changelog ## [v0.3.0](https://github.com/pyapp-kit/app-model/tree/v0.3.0) (2024-09-17) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.8...v0.3.0) **Implemented enhancements:** - feat: Include command/control-0 for OriginalSize in StandardKeyBindings [\#220](https://github.com/pyapp-kit/app-model/pull/220) ([psobolewskiPhD](https://github.com/psobolewskiPhD)) - feat: add support for `.svg` file paths as `Action` icon [\#219](https://github.com/pyapp-kit/app-model/pull/219) ([dalthviz](https://github.com/dalthviz)) - feat: add action keybinding info over tooltip [\#218](https://github.com/pyapp-kit/app-model/pull/218) ([dalthviz](https://github.com/dalthviz)) ## [v0.2.8](https://github.com/pyapp-kit/app-model/tree/v0.2.8) (2024-07-19) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.7...v0.2.8) **Implemented enhancements:** - feat: more flexible keybinding parser [\#213](https://github.com/pyapp-kit/app-model/pull/213) ([tlambert03](https://github.com/tlambert03)) - feat: Add `filter_keybinding` to `KeyBindingRegistry` [\#212](https://github.com/pyapp-kit/app-model/pull/212) ([lucyleeow](https://github.com/lucyleeow)) - feat: add a way to get a user-facing string representation of keybindings [\#211](https://github.com/pyapp-kit/app-model/pull/211) ([dalthviz](https://github.com/dalthviz)) - feat: add Sequences to expressions [\#202](https://github.com/pyapp-kit/app-model/pull/202) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - perf: faster `Expr.eval` by precompiling expressions [\#197](https://github.com/pyapp-kit/app-model/pull/197) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.7](https://github.com/pyapp-kit/app-model/tree/v0.2.7) (2024-05-08) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.6...v0.2.7) **Implemented enhancements:** - feat: give registries more control over registration of actions [\#194](https://github.com/pyapp-kit/app-model/pull/194) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.6](https://github.com/pyapp-kit/app-model/tree/v0.2.6) (2024-03-25) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.5...v0.2.6) **Fixed bugs:** - fix: Do not use lambda in QMenuItemAction.destroyed callback [\#183](https://github.com/pyapp-kit/app-model/pull/183) ([Czaki](https://github.com/Czaki)) ## [v0.2.5](https://github.com/pyapp-kit/app-model/tree/v0.2.5) (2024-03-18) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.4...v0.2.5) **Fixed bugs:** - fix: handle qmods like QT5 for pyside6 not 6.4 [\#179](https://github.com/pyapp-kit/app-model/pull/179) ([psobolewskiPhD](https://github.com/psobolewskiPhD)) **Merged pull requests:** - chore: add format to pre-commit [\#182](https://github.com/pyapp-kit/app-model/pull/182) ([tlambert03](https://github.com/tlambert03)) - chore: use ruff format instead of black [\#181](https://github.com/pyapp-kit/app-model/pull/181) ([tlambert03](https://github.com/tlambert03)) - ci: change test suite to cover more qt versions [\#180](https://github.com/pyapp-kit/app-model/pull/180) ([tlambert03](https://github.com/tlambert03)) - ci\(dependabot\): bump softprops/action-gh-release from 1 to 2 [\#178](https://github.com/pyapp-kit/app-model/pull/178) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci: Add testing of napari/utils/\_tests/test\_key\_bindings.py [\#173](https://github.com/pyapp-kit/app-model/pull/173) ([Czaki](https://github.com/Czaki)) - ci: \[pre-commit.ci\] autoupdate [\#172](https://github.com/pyapp-kit/app-model/pull/172) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) ## [v0.2.4](https://github.com/pyapp-kit/app-model/tree/v0.2.4) (2023-12-21) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.3...v0.2.4) **Implemented enhancements:** - feat: export register\_action function at top level [\#170](https://github.com/pyapp-kit/app-model/pull/170) ([tlambert03](https://github.com/tlambert03)) **Documentation:** - Fix doc typo for `register_action` [\#168](https://github.com/pyapp-kit/app-model/pull/168) ([aganders3](https://github.com/aganders3)) - docs: Add ref to in n out getting started [\#167](https://github.com/pyapp-kit/app-model/pull/167) ([lucyleeow](https://github.com/lucyleeow)) **Merged pull requests:** - ci\(dependabot\): bump aganders3/headless-gui from 1 to 2 [\#165](https://github.com/pyapp-kit/app-model/pull/165) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci\(dependabot\): bump actions/setup-python from 4 to 5 [\#164](https://github.com/pyapp-kit/app-model/pull/164) ([dependabot[bot]](https://github.com/apps/dependabot)) - refactor: remove comparison between Keybinding and string [\#146](https://github.com/pyapp-kit/app-model/pull/146) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.3](https://github.com/pyapp-kit/app-model/tree/v0.2.3) (2023-12-12) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.2...v0.2.3) **Implemented enhancements:** - feat: add top level Application.context [\#145](https://github.com/pyapp-kit/app-model/pull/145) ([tlambert03](https://github.com/tlambert03)) - feat: add `CommandRule.icon_visible_in_menu` [\#135](https://github.com/pyapp-kit/app-model/pull/135) ([tlambert03](https://github.com/tlambert03)) - feat: return QModelToolBar from call to QModelMainWindow.addModelToolBar [\#134](https://github.com/pyapp-kit/app-model/pull/134) ([tlambert03](https://github.com/tlambert03)) - feat: accept single string id as menu key in Actions.menus [\#133](https://github.com/pyapp-kit/app-model/pull/133) ([tlambert03](https://github.com/tlambert03)) - feat: support iconify icon keys [\#130](https://github.com/pyapp-kit/app-model/pull/130) ([tlambert03](https://github.com/tlambert03)) - feat: Show shortcut in `KeyBinding.__repr__` [\#126](https://github.com/pyapp-kit/app-model/pull/126) ([Czaki](https://github.com/Czaki)) - feat: support py312 [\#124](https://github.com/pyapp-kit/app-model/pull/124) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: catch runtime error on QModelSubmenu cleanup [\#151](https://github.com/pyapp-kit/app-model/pull/151) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - test: add test for mult\_file [\#140](https://github.com/pyapp-kit/app-model/pull/140) ([tlambert03](https://github.com/tlambert03)) - test: enforce 100 percent test coverage on project [\#136](https://github.com/pyapp-kit/app-model/pull/136) ([tlambert03](https://github.com/tlambert03)) **Documentation:** - docs: remove minify plugin [\#154](https://github.com/pyapp-kit/app-model/pull/154) ([tlambert03](https://github.com/tlambert03)) - docs: use griffe-fieldz instead of builtin-extension [\#149](https://github.com/pyapp-kit/app-model/pull/149) ([tlambert03](https://github.com/tlambert03)) - docs: documentation overhaul [\#142](https://github.com/pyapp-kit/app-model/pull/142) ([tlambert03](https://github.com/tlambert03)) - docs: Fix bullet points in `Exp` [\#125](https://github.com/pyapp-kit/app-model/pull/125) ([lucyleeow](https://github.com/lucyleeow)) **Merged pull requests:** - chore: Provide information about callback registered [\#166](https://github.com/pyapp-kit/app-model/pull/166) ([Czaki](https://github.com/Czaki)) - style: type cleanup/modernization [\#156](https://github.com/pyapp-kit/app-model/pull/156) ([tlambert03](https://github.com/tlambert03)) - ci: \[pre-commit.ci\] autoupdate [\#152](https://github.com/pyapp-kit/app-model/pull/152) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci)) - ci: Update CI workflow to include reusable test [\#150](https://github.com/pyapp-kit/app-model/pull/150) ([tlambert03](https://github.com/tlambert03)) - style: better qt typing [\#141](https://github.com/pyapp-kit/app-model/pull/141) ([tlambert03](https://github.com/tlambert03)) - ci: Unpin pyside6 in tests [\#138](https://github.com/pyapp-kit/app-model/pull/138) ([tlambert03](https://github.com/tlambert03)) - chore: remove setup.py, update ruff [\#131](https://github.com/pyapp-kit/app-model/pull/131) ([tlambert03](https://github.com/tlambert03)) - refactor: use pydantic-compat [\#128](https://github.com/pyapp-kit/app-model/pull/128) ([tlambert03](https://github.com/tlambert03)) ## [v0.2.2](https://github.com/pyapp-kit/app-model/tree/v0.2.2) (2023-09-25) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.1...v0.2.2) **Fixed bugs:** - fix: propagate \_recurse value in `QModelSubmenu.update_from_context` method [\#122](https://github.com/pyapp-kit/app-model/pull/122) ([Czaki](https://github.com/Czaki)) **Merged pull requests:** - ci\(dependabot\): bump actions/checkout from 3 to 4 [\#121](https://github.com/pyapp-kit/app-model/pull/121) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.2.1](https://github.com/pyapp-kit/app-model/tree/v0.2.1) (2023-08-30) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.2.0...v0.2.1) **Fixed bugs:** - fix: properly connect events for Contexts comprised of other Contexts [\#119](https://github.com/pyapp-kit/app-model/pull/119) ([kne42](https://github.com/kne42)) ## [v0.2.0](https://github.com/pyapp-kit/app-model/tree/v0.2.0) (2023-07-13) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.1.4...v0.2.0) **Implemented enhancements:** - feat: map win and cmd to meta [\#113](https://github.com/pyapp-kit/app-model/pull/113) ([tlambert03](https://github.com/tlambert03)) - feat: support pydantic v2 [\#98](https://github.com/pyapp-kit/app-model/pull/98) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: Amend preferences `StandardKeyBinding` [\#104](https://github.com/pyapp-kit/app-model/pull/104) ([lucyleeow](https://github.com/lucyleeow)) - fix: fix menu titles in QtModelMenuBar [\#102](https://github.com/pyapp-kit/app-model/pull/102) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - ci: test pydantic1 [\#115](https://github.com/pyapp-kit/app-model/pull/115) ([tlambert03](https://github.com/tlambert03)) **Documentation:** - docs: Move `_expressions.py` docstring to be included in documentation [\#107](https://github.com/pyapp-kit/app-model/pull/107) ([lucyleeow](https://github.com/lucyleeow)) ## [v0.1.4](https://github.com/pyapp-kit/app-model/tree/v0.1.4) (2023-04-06) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.1.3...v0.1.4) **Merged pull requests:** - build: pin pydantic \< 2 [\#96](https://github.com/pyapp-kit/app-model/pull/96) ([tlambert03](https://github.com/tlambert03)) ## [v0.1.3](https://github.com/pyapp-kit/app-model/tree/v0.1.3) (2023-04-06) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.1.2...v0.1.3) **Fixed bugs:** - fix: don't use mixin for menus [\#95](https://github.com/pyapp-kit/app-model/pull/95) ([tlambert03](https://github.com/tlambert03)) ## [v0.1.2](https://github.com/pyapp-kit/app-model/tree/v0.1.2) (2023-03-07) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.1.1...v0.1.2) **Fixed bugs:** - fix: Fix typo in execute\_command method [\#86](https://github.com/pyapp-kit/app-model/pull/86) ([davidbrochart](https://github.com/davidbrochart)) - fix: Fix ctrl meta key swap \(for real this time \(i think\)\) [\#82](https://github.com/pyapp-kit/app-model/pull/82) ([kne42](https://github.com/kne42)) **Tests & CI:** - Precommit updates [\#88](https://github.com/pyapp-kit/app-model/pull/88) ([tlambert03](https://github.com/tlambert03)) **Documentation:** - docs: fix docs build \(add ToggleRule\) [\#79](https://github.com/pyapp-kit/app-model/pull/79) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - build: use hatch for build and ruff for linting [\#81](https://github.com/pyapp-kit/app-model/pull/81) ([tlambert03](https://github.com/tlambert03)) - chore: rename napari org to pyapp-kit [\#78](https://github.com/pyapp-kit/app-model/pull/78) ([tlambert03](https://github.com/tlambert03)) ## [v0.1.1](https://github.com/pyapp-kit/app-model/tree/v0.1.1) (2022-11-10) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.1.0...v0.1.1) **Implemented enhancements:** - feat: support python 3.11 [\#77](https://github.com/pyapp-kit/app-model/pull/77) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: fix unsupported operand [\#76](https://github.com/pyapp-kit/app-model/pull/76) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - refactor: Use a dict \(as an ordered set\) instead of a list for menus registry [\#74](https://github.com/pyapp-kit/app-model/pull/74) ([aganders3](https://github.com/aganders3)) - ci\(dependabot\): bump styfle/cancel-workflow-action from 0.10.1 to 0.11.0 [\#72](https://github.com/pyapp-kit/app-model/pull/72) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.1.0](https://github.com/pyapp-kit/app-model/tree/v0.1.0) (2022-10-10) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.9...v0.1.0) **Fixed bugs:** - fix: properly detect when ctrl and meta swapped on mac [\#64](https://github.com/pyapp-kit/app-model/pull/64) ([kne42](https://github.com/kne42)) - fix various bugs [\#63](https://github.com/pyapp-kit/app-model/pull/63) ([kne42](https://github.com/kne42)) **Merged pull requests:** - chore: changelog v0.1.0 [\#69](https://github.com/pyapp-kit/app-model/pull/69) ([tlambert03](https://github.com/tlambert03)) - feat: convert keybinding to normal class [\#68](https://github.com/pyapp-kit/app-model/pull/68) ([kne42](https://github.com/kne42)) - ci\(dependabot\): bump styfle/cancel-workflow-action from 0.10.0 to 0.10.1 [\#66](https://github.com/pyapp-kit/app-model/pull/66) ([dependabot[bot]](https://github.com/apps/dependabot)) ## [v0.0.9](https://github.com/pyapp-kit/app-model/tree/v0.0.9) (2022-08-26) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.8...v0.0.9) **Implemented enhancements:** - feat: eval expr when creating menus [\#61](https://github.com/pyapp-kit/app-model/pull/61) ([tlambert03](https://github.com/tlambert03)) **Documentation:** - docs: fix a few typos in docs [\#60](https://github.com/pyapp-kit/app-model/pull/60) ([alisterburt](https://github.com/alisterburt)) ## [v0.0.8](https://github.com/pyapp-kit/app-model/tree/v0.0.8) (2022-08-21) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.7...v0.0.8) **Implemented enhancements:** - feat: add ToggleRule for toggleable Actions [\#59](https://github.com/pyapp-kit/app-model/pull/59) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - ci: add napari tests [\#57](https://github.com/pyapp-kit/app-model/pull/57) ([tlambert03](https://github.com/tlambert03)) **Merged pull requests:** - refactor: switch to extra ignore [\#58](https://github.com/pyapp-kit/app-model/pull/58) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.7](https://github.com/pyapp-kit/app-model/tree/v0.0.7) (2022-07-24) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.6...v0.0.7) **Merged pull requests:** - build: relax runtime typing extensions dependency [\#49](https://github.com/pyapp-kit/app-model/pull/49) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.6](https://github.com/pyapp-kit/app-model/tree/v0.0.6) (2022-07-24) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.5...v0.0.6) **Implemented enhancements:** - feat: add get\_app class method to Application [\#48](https://github.com/pyapp-kit/app-model/pull/48) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.5](https://github.com/pyapp-kit/app-model/tree/v0.0.5) (2022-07-23) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.4...v0.0.5) **Implemented enhancements:** - test: more test coverage [\#46](https://github.com/pyapp-kit/app-model/pull/46) ([tlambert03](https://github.com/tlambert03)) - feat: add register\_actions [\#45](https://github.com/pyapp-kit/app-model/pull/45) ([tlambert03](https://github.com/tlambert03)) - fix: small getitem fixes for napari [\#44](https://github.com/pyapp-kit/app-model/pull/44) ([tlambert03](https://github.com/tlambert03)) - feat: qt key conversion helpers [\#43](https://github.com/pyapp-kit/app-model/pull/43) ([tlambert03](https://github.com/tlambert03)) **Fixed bugs:** - fix: fix sorting when group is None [\#42](https://github.com/pyapp-kit/app-model/pull/42) ([tlambert03](https://github.com/tlambert03)) **Tests & CI:** - tests: more qtest coverage [\#47](https://github.com/pyapp-kit/app-model/pull/47) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.4](https://github.com/pyapp-kit/app-model/tree/v0.0.4) (2022-07-16) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.3...v0.0.4) **Implemented enhancements:** - feat: add toggled to command [\#41](https://github.com/pyapp-kit/app-model/pull/41) ([tlambert03](https://github.com/tlambert03)) - feat: raise\_synchronous option, and expose app classes [\#40](https://github.com/pyapp-kit/app-model/pull/40) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.3](https://github.com/pyapp-kit/app-model/tree/v0.0.3) (2022-07-14) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.2...v0.0.3) **Merged pull requests:** - fix: expression hashing and repr [\#39](https://github.com/pyapp-kit/app-model/pull/39) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.2](https://github.com/pyapp-kit/app-model/tree/v0.0.2) (2022-07-13) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/v0.0.1...v0.0.2) **Merged pull requests:** - chore: move tlambert03/app-model to napari [\#38](https://github.com/pyapp-kit/app-model/pull/38) ([tlambert03](https://github.com/tlambert03)) - fix: allow older qtpy [\#37](https://github.com/pyapp-kit/app-model/pull/37) ([tlambert03](https://github.com/tlambert03)) - docs: Add Documentation [\#36](https://github.com/pyapp-kit/app-model/pull/36) ([tlambert03](https://github.com/tlambert03)) - feat: cache qactions \[wip\] [\#35](https://github.com/pyapp-kit/app-model/pull/35) ([tlambert03](https://github.com/tlambert03)) - feat: updating demo [\#34](https://github.com/pyapp-kit/app-model/pull/34) ([tlambert03](https://github.com/tlambert03)) - build: pin min typing extensions [\#33](https://github.com/pyapp-kit/app-model/pull/33) ([tlambert03](https://github.com/tlambert03)) - feat: add standard keybindings [\#32](https://github.com/pyapp-kit/app-model/pull/32) ([tlambert03](https://github.com/tlambert03)) - feat: frozen models [\#31](https://github.com/pyapp-kit/app-model/pull/31) ([tlambert03](https://github.com/tlambert03)) - refactor: restrict to only one command per id [\#30](https://github.com/pyapp-kit/app-model/pull/30) ([tlambert03](https://github.com/tlambert03)) ## [v0.0.1](https://github.com/pyapp-kit/app-model/tree/v0.0.1) (2022-07-06) [Full Changelog](https://github.com/pyapp-kit/app-model/compare/3a1e61cc7b0b249a9f2e3fce9cfa6cf6b766cb2a...v0.0.1) **Merged pull requests:** - refactor: a number of fixes [\#26](https://github.com/pyapp-kit/app-model/pull/26) ([tlambert03](https://github.com/tlambert03)) - feat: demo app [\#24](https://github.com/pyapp-kit/app-model/pull/24) ([tlambert03](https://github.com/tlambert03)) - test: fix pre-test [\#23](https://github.com/pyapp-kit/app-model/pull/23) ([tlambert03](https://github.com/tlambert03)) - build: add py.typed [\#22](https://github.com/pyapp-kit/app-model/pull/22) ([tlambert03](https://github.com/tlambert03)) - feat: add injection model to app [\#21](https://github.com/pyapp-kit/app-model/pull/21) ([tlambert03](https://github.com/tlambert03)) - feat: allow callbacks as strings [\#18](https://github.com/pyapp-kit/app-model/pull/18) ([tlambert03](https://github.com/tlambert03)) - refactor: create backend folder [\#17](https://github.com/pyapp-kit/app-model/pull/17) ([tlambert03](https://github.com/tlambert03)) - feat: Keybindings! [\#16](https://github.com/pyapp-kit/app-model/pull/16) ([tlambert03](https://github.com/tlambert03)) - feat: more qt support, submenus, etc [\#11](https://github.com/pyapp-kit/app-model/pull/11) ([tlambert03](https://github.com/tlambert03)) - feat: Add qt module [\#10](https://github.com/pyapp-kit/app-model/pull/10) ([tlambert03](https://github.com/tlambert03)) - feat: combine app model [\#9](https://github.com/pyapp-kit/app-model/pull/9) ([tlambert03](https://github.com/tlambert03)) - test: more test coverage, organization, and documentation [\#7](https://github.com/pyapp-kit/app-model/pull/7) ([tlambert03](https://github.com/tlambert03)) - fix: Fix windows keybindings tests [\#5](https://github.com/pyapp-kit/app-model/pull/5) ([tlambert03](https://github.com/tlambert03)) - ci\(dependabot\): bump codecov/codecov-action from 2 to 3 [\#2](https://github.com/pyapp-kit/app-model/pull/2) ([dependabot[bot]](https://github.com/apps/dependabot)) - ci\(dependabot\): bump styfle/cancel-workflow-action from 0.9.1 to 0.10.0 [\#1](https://github.com/pyapp-kit/app-model/pull/1) ([dependabot[bot]](https://github.com/apps/dependabot)) \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* app-model-0.3.0/LICENSE000066400000000000000000000027071467241150700144120ustar00rootroot00000000000000Copyright (c) 2022, Talley Lambert Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. app-model-0.3.0/README.md000066400000000000000000000020221467241150700146520ustar00rootroot00000000000000# app-model [![License](https://img.shields.io/pypi/l/app-model.svg?color=green)](https://github.com/pyapp-kit/app-model/raw/main/LICENSE) [![PyPI](https://img.shields.io/pypi/v/app-model.svg?color=green)](https://pypi.org/project/app-model) [![Python Version](https://img.shields.io/pypi/pyversions/app-model.svg?color=green)](https://python.org) [![CI](https://github.com/pyapp-kit/app-model/actions/workflows/ci.yml/badge.svg)](https://github.com/pyapp-kit/app-model/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/pyapp-kit/app-model/branch/main/graph/badge.svg)](https://codecov.io/gh/pyapp-kit/app-model) [![Documentation Status](https://readthedocs.org/projects/app-model/badge/?version=latest)](https://app-model.readthedocs.io/en/latest/?badge=latest) Generic application schema implemented in python. This is a schema for declarative organization of application data, such as menus, keybindings, actions/commands, etc... Inspired by the VS-Code application model docs at https://app-model.readthedocs.io/en/latest/ app-model-0.3.0/codecov.yml000066400000000000000000000001641467241150700155450ustar00rootroot00000000000000coverage: status: patch: default: target: 100% project: default: target: 100% app-model-0.3.0/demo/000077500000000000000000000000001467241150700143235ustar00rootroot00000000000000app-model-0.3.0/demo/images/000077500000000000000000000000001467241150700155705ustar00rootroot00000000000000app-model-0.3.0/demo/images/about.svg000066400000000000000000000012121467241150700174170ustar00rootroot00000000000000app-model-0.3.0/demo/keybinding_helper.py000066400000000000000000000003751467241150700203640ustar00rootroot00000000000000import sys from qtpy.QtWidgets import QApplication from app_model.backends.qt import QModelKeyBindingEdit app = QApplication(sys.argv) w = QModelKeyBindingEdit() w.editingFinished.connect(lambda: print(w.keyBinding())) w.show() sys.exit(app.exec_()) app-model-0.3.0/demo/model_app.py000066400000000000000000000202711467241150700166370ustar00rootroot00000000000000from pathlib import Path from typing import List from qtpy.QtCore import QFile, QFileInfo, QSaveFile, Qt, QTextStream from qtpy.QtWidgets import QApplication, QFileDialog, QMessageBox, QTextEdit from app_model import Application, types from app_model.backends.qt import QModelMainWindow from app_model.expressions import create_context class MainWindow(QModelMainWindow): def __init__(self, app: Application): super().__init__(app) self._cur_file: str = "" self._text_edit = QTextEdit() self._text_edit.copyAvailable.connect(self._update_context) self.setCentralWidget(self._text_edit) self.setModelMenuBar([MenuId.FILE, MenuId.EDIT, MenuId.HELP]) self.addModelToolBar(MenuId.FILE, exclude={CommandId.SAVE_AS, CommandId.EXIT}) self.addModelToolBar(MenuId.EDIT) self.addModelToolBar(MenuId.HELP) self.statusBar().showMessage("Ready") self.set_current_file("") self._ctx = create_context(self) self._ctx.changed.connect(self._on_context_changed) self._ctx["copyAvailable"] = False def _update_context(self, available: bool) -> None: self._ctx["copyAvailable"] = available def _on_context_changed(self) -> None: self.menuBar().update_from_context(self._ctx) def set_current_file(self, fileName: str) -> None: self._cur_file = fileName self._text_edit.document().setModified(False) self.setWindowModified(False) if self._cur_file: shown_name = QFileInfo(self._cur_file).fileName() else: shown_name = "untitled.txt" self.setWindowTitle(f"{shown_name}[*] - Application") def save(self) -> bool: return self.save_file(self._cur_file) if self._cur_file else self.save_as() def save_as(self) -> bool: fileName, _ = QFileDialog.getSaveFileName(self) if fileName: return self.save_file(fileName) return False def save_file(self, fileName: str) -> bool: error = None QApplication.setOverrideCursor(Qt.WaitCursor) file = QSaveFile(fileName) if file.open(QFile.OpenModeFlag.WriteOnly | QFile.OpenModeFlag.Text): # type: ignore outf = QTextStream(file) outf << self._text_edit.toPlainText() if not file.commit(): reason = file.errorString() error = f"Cannot write file {fileName}:\n{reason}." else: reason = file.errorString() error = f"Cannot open file {fileName}:\n{reason}." QApplication.restoreOverrideCursor() if error: QMessageBox.warning(self, "Application", error) return False return True def maybe_save(self) -> bool: if self._text_edit.document().isModified(): ret = QMessageBox.warning( self, "Application", "The document has been modified.\nDo you want to save " "your changes?", QMessageBox.StandardButton.Save # type: ignore | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, ) if ret == QMessageBox.StandardButton.Save: return self.save() elif ret == QMessageBox.StandardButton.Cancel: return False return True def new_file(self) -> None: if self.maybe_save(): self._text_edit.clear() self.set_current_file("") def open_file(self) -> None: if self.maybe_save(): fileName, _ = QFileDialog.getOpenFileName(self) if fileName: self.load_file(fileName) def load_file(self, fileName: str) -> None: file = QFile(fileName) if not file.open(QFile.OpenModeFlag.ReadOnly | QFile.OpenModeFlag.Text): # type: ignore reason = file.errorString() QMessageBox.warning( self, "Application", f"Cannot read file {fileName}:\n{reason}." ) return inf = QTextStream(file) QApplication.setOverrideCursor(Qt.WaitCursor) self._text_edit.setPlainText(inf.readAll()) QApplication.restoreOverrideCursor() self.set_current_file(fileName) self.statusBar().showMessage("File loaded", 2000) def about(self) -> None: QMessageBox.about( self, "About Application", "The Application example demonstrates how to write " "modern GUI applications using Qt, with a menu bar, " "toolbars, and a status bar.", ) def cut(self) -> None: self._text_edit.cut() def copy(self) -> None: self._text_edit.copy() def paste(self) -> None: self._text_edit.paste() def close(self) -> bool: return super().close() # Actions defined declaratively outside of QMainWindow class ... # menus and toolbars will be made and added automatically class MenuId: FILE = "file" EDIT = "edit" HELP = "help" class CommandId: SAVE_AS = "save_file_as" EXIT = "exit" ABOUT_ICON_PATH = Path(__file__).parent / "images" / "about.svg" ACTIONS: List[types.Action] = [ types.Action( id="new_file", icon="fa6-solid:file-circle-plus", title="New", keybindings=[types.StandardKeyBinding.New], status_tip="Create a new file", menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], callback=MainWindow.new_file, ), types.Action( id="open_file", icon="fa6-solid:folder-open", title="Open...", keybindings=[types.StandardKeyBinding.Open], status_tip="Open an existing file", menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], callback=MainWindow.open_file, ), types.Action( id="save_file", icon="fa6-solid:floppy-disk", title="Save", keybindings=[types.StandardKeyBinding.Save], status_tip="Save the document to disk", menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], callback=MainWindow.save, ), types.Action( id=CommandId.SAVE_AS, title="Save As...", keybindings=[types.StandardKeyBinding.SaveAs], status_tip="Save the document under a new name", menus=[{"id": MenuId.FILE, "group": "1_loadsave"}], callback=MainWindow.save_as, ), types.Action( id=CommandId.EXIT, title="Exit", keybindings=[types.StandardKeyBinding.Quit], status_tip="Exit the application", menus=[{"id": MenuId.FILE, "group": "3_launchexit"}], callback=MainWindow.close, ), types.Action( id="cut", icon="fa6-solid:scissors", title="Cut", keybindings=[types.StandardKeyBinding.Cut], enablement="copyAvailable", status_tip="Cut the current selection's contents to the clipboard", menus=[{"id": MenuId.EDIT}], callback=MainWindow.cut, ), types.Action( id="copy", icon="fa6-solid:copy", title="Copy", keybindings=[types.StandardKeyBinding.Copy], enablement="copyAvailable", status_tip="Copy the current selection's contents to the clipboard", menus=[{"id": MenuId.EDIT}], callback=MainWindow.copy, ), types.Action( id="paste", icon="fa6-solid:paste", title="Paste", keybindings=[types.StandardKeyBinding.Paste], status_tip="Paste the clipboard's contents into the current selection", menus=[{"id": MenuId.EDIT}], callback=MainWindow.paste, ), types.Action( id="about", icon=f"file:///{ABOUT_ICON_PATH}", title="About", status_tip="Show the application's About box", menus=[{"id": MenuId.HELP}], callback=MainWindow.about, ), ] # Main setup if __name__ == "__main__": app = Application(name="my_app") for action in ACTIONS: app.register_action(action) qapp = QApplication.instance() or QApplication([]) qapp.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus) main_win = MainWindow(app=app) app.injection_store.register_provider(lambda: main_win, MainWindow) main_win.show() qapp.exec_() app-model-0.3.0/demo/multi_file/000077500000000000000000000000001467241150700164545ustar00rootroot00000000000000app-model-0.3.0/demo/multi_file/__init__.py000066400000000000000000000000001467241150700205530ustar00rootroot00000000000000app-model-0.3.0/demo/multi_file/__main__.py000066400000000000000000000003731467241150700205510ustar00rootroot00000000000000import pathlib import sys sys.path.append(str(pathlib.Path(__file__).parent.parent)) from qtpy.QtWidgets import QApplication from multi_file.app import MyApp qapp = QApplication.instance() or QApplication([]) app = MyApp() app.show() qapp.exec_() app-model-0.3.0/demo/multi_file/actions.py000066400000000000000000000041151467241150700204670ustar00rootroot00000000000000from typing import List from app_model.types import Action, KeyBindingRule, KeyCode, KeyMod, MenuRule from . import functions from .constants import CommandId, MenuId ACTIONS: List[Action] = [ Action( id=CommandId.OPEN, title="Open", icon="fa6-solid:folder-open", callback=functions.open_file, menus=[MenuRule(id=MenuId.FILE)], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyO)], ), Action( id=CommandId.CLOSE, title="Close", icon="fa-solid:window-close", callback=functions.close, menus=[MenuRule(id=MenuId.FILE)], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyW)], ), Action( id=CommandId.UNDO, title="Undo", icon="fa-solid:undo", callback=functions.undo, menus=[MenuRule(id=MenuId.EDIT, group="1_undo_redo")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyZ)], ), Action( id=CommandId.REDO, title="Redo", icon="fa6-solid:rotate-right", callback=functions.redo, menus=[MenuRule(id=MenuId.EDIT, group="1_undo_redo")], keybindings=[ KeyBindingRule(primary=KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ) ], ), Action( id=CommandId.CUT, title="Cut", icon="fa-solid:cut", callback=functions.cut, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyX)], ), Action( id=CommandId.COPY, title="Copy", icon="fa6-solid:copy", callback=functions.copy, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyC)], ), Action( id=CommandId.PASTE, title="Paste", icon="fa6-solid:paste", callback=functions.paste, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyV)], ), ] app-model-0.3.0/demo/multi_file/app.py000066400000000000000000000012011467241150700176000ustar00rootroot00000000000000from app_model import Application from app_model.backends.qt import QModelMainWindow from .actions import ACTIONS from .constants import MenuId class MyApp(Application): def __init__(self) -> None: super().__init__("my_application") # ACTIONS is a list of Action objects. for action in ACTIONS: self.register_action(action) self._main_window = QModelMainWindow(app=self) # This will build a menu bar based on these menus self._main_window.setModelMenuBar([MenuId.FILE, MenuId.EDIT]) def show(self) -> None: """Show the app""" self._main_window.show() app-model-0.3.0/demo/multi_file/constants.py000066400000000000000000000007141467241150700210440ustar00rootroot00000000000000from enum import Enum class CommandId(str, Enum): OPEN = "myapp.open" CLOSE = "myapp.close" SAVE = "myapp.save" QUIT = "myapp.quit" UNDO = "myapp.undo" REDO = "myapp.redo" COPY = "myapp.copy" PASTE = "myapp.paste" CUT = "myapp.cut" def __str__(self) -> str: return self.value class MenuId(str, Enum): FILE = "myapp/file" EDIT = "myapp/edit" def __str__(self) -> str: return self.value app-model-0.3.0/demo/multi_file/functions.py000066400000000000000000000005741467241150700210440ustar00rootroot00000000000000from qtpy.QtWidgets import QApplication, QFileDialog def open_file(): name, _ = QFileDialog.getOpenFileName() print("Open file:", name) def close(): QApplication.activeWindow().close() print("close") def undo(): print("undo") def redo(): print("redo") def cut(): print("cut") def copy(): print("copy") def paste(): print("paste") app-model-0.3.0/demo/qapplication.py000066400000000000000000000201741467241150700173650ustar00rootroot00000000000000# Copyright (C) 2013 Riverbank Computing Limited. # Copyright (C) 2022 The Qt Company Ltd. # SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause from fonticon_fa6 import FA6S from qtpy.QtCore import QFile, QFileInfo, QSaveFile, Qt, QTextStream from qtpy.QtGui import QKeySequence from qtpy.QtWidgets import ( QAction, QApplication, QFileDialog, QMainWindow, QMessageBox, QTextEdit, ) from superqt import fonticon class MainWindow(QMainWindow): def __init__(self): super().__init__() self._cur_file = "" self._text_edit = QTextEdit() self.setCentralWidget(self._text_edit) self.create_actions() self.create_menus() self.create_tool_bars() self.create_status_bar() self.set_current_file("") def new_file(self): if self.maybe_save(): self._text_edit.clear() self.set_current_file("") def open(self): if self.maybe_save(): fileName, filtr = QFileDialog.getOpenFileName(self) if fileName: self.load_file(fileName) def save(self): return self.save_file(self._cur_file) if self._cur_file else self.save_as() def save_as(self): fileName, filtr = QFileDialog.getSaveFileName(self) if fileName: return self.save_file(fileName) return False def about(self): QMessageBox.about( self, "About Application", "The Application example demonstrates how to write " "modern GUI applications using Qt, with a menu bar, " "toolbars, and a status bar.", ) def create_actions(self): self._new_act = QAction( fonticon.icon(FA6S.file_circle_plus), "&New", self, shortcut=QKeySequence.StandardKey.New, statusTip="Create a new file", triggered=self.new_file, ) self._open_act = QAction( fonticon.icon(FA6S.folder_open), "&Open...", self, shortcut=QKeySequence.StandardKey.Open, statusTip="Open an existing file", triggered=self.open, ) self._save_act = QAction( fonticon.icon(FA6S.floppy_disk), "&Save", self, shortcut=QKeySequence.StandardKey.Save, statusTip="Save the document to disk", triggered=self.save, ) self._save_as_act = QAction( "Save &As...", self, shortcut=QKeySequence.StandardKey.SaveAs, statusTip="Save the document under a new name", triggered=self.save_as, ) self._exit_act = QAction( "E&xit", self, shortcut="Ctrl+Q", statusTip="Exit the application", triggered=self.close, ) self._cut_act = QAction( fonticon.icon(FA6S.scissors), "Cu&t", self, shortcut=QKeySequence.StandardKey.Cut, statusTip="Cut the current selection's contents to the clipboard", triggered=self._text_edit.cut, ) self._copy_act = QAction( fonticon.icon(FA6S.copy), "&Copy", self, shortcut=QKeySequence.StandardKey.Copy, statusTip="Copy the current selection's contents to the clipboard", triggered=self._text_edit.copy, ) self._paste_act = QAction( fonticon.icon(FA6S.paste), "&Paste", self, shortcut=QKeySequence.StandardKey.Paste, statusTip="Paste the clipboard's contents into the current selection", triggered=self._text_edit.paste, ) self._about_act = QAction( "&About", self, statusTip="Show the application's About box", triggered=self.about, ) self._cut_act.setEnabled(False) self._copy_act.setEnabled(False) self._text_edit.copyAvailable.connect(self._cut_act.setEnabled) self._text_edit.copyAvailable.connect(self._copy_act.setEnabled) def create_menus(self): self._file_menu = self.menuBar().addMenu("&File") self._file_menu.addAction(self._new_act) self._file_menu.addAction(self._open_act) self._file_menu.addAction(self._save_act) self._file_menu.addAction(self._save_as_act) self._file_menu.addSeparator() self._file_menu.addAction(self._exit_act) self._edit_menu = self.menuBar().addMenu("&Edit") self._edit_menu.addAction(self._cut_act) self._edit_menu.addAction(self._copy_act) self._edit_menu.addAction(self._paste_act) self.menuBar().addSeparator() self._help_menu = self.menuBar().addMenu("&Help") self._help_menu.addAction(self._about_act) def create_tool_bars(self): self._file_tool_bar = self.addToolBar("File") self._file_tool_bar.addAction(self._new_act) self._file_tool_bar.addAction(self._open_act) self._file_tool_bar.addAction(self._save_act) self._edit_tool_bar = self.addToolBar("Edit") self._edit_tool_bar.addAction(self._cut_act) self._edit_tool_bar.addAction(self._copy_act) self._edit_tool_bar.addAction(self._paste_act) def create_status_bar(self): self.statusBar().showMessage("Ready") def maybe_save(self): if self._text_edit.document().isModified(): ret = QMessageBox.warning( self, "Application", "The document has been modified.\nDo you want to save " "your changes?", QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel, ) if ret == QMessageBox.StandardButton.Save: return self.save() elif ret == QMessageBox.StandardButton.Cancel: return False return True def load_file(self, fileName): file = QFile(fileName) if not file.open(QFile.OpenModeFlag.ReadOnly | QFile.OpenModeFlag.Text): reason = file.errorString() QMessageBox.warning( self, "Application", f"Cannot read file {fileName}:\n{reason}." ) return inf = QTextStream(file) QApplication.setOverrideCursor(Qt.WaitCursor) self._text_edit.setPlainText(inf.readAll()) QApplication.restoreOverrideCursor() self.set_current_file(fileName) self.statusBar().showMessage("File loaded", 2000) def save_file(self, fileName): error = None QApplication.setOverrideCursor(Qt.WaitCursor) file = QSaveFile(fileName) if file.open(QFile.OpenModeFlag.WriteOnly | QFile.OpenModeFlag.Text): outf = QTextStream(file) outf << self._text_edit.toPlainText() if not file.commit(): reason = file.errorString() error = f"Cannot write file {fileName}:\n{reason}." else: reason = file.errorString() error = f"Cannot open file {fileName}:\n{reason}." QApplication.restoreOverrideCursor() if error: QMessageBox.warning(self, "Application", error) return False self.set_current_file(fileName) self.statusBar().showMessage("File saved", 2000) return True def set_current_file(self, fileName: str): self._cur_file = fileName self._text_edit.document().setModified(False) self.setWindowModified(False) if self._cur_file: shown_name = self.stripped_name(self._cur_file) else: shown_name = "untitled.txt" self.setWindowTitle(f"{shown_name}[*] - Application") def stripped_name(self, fullFileName: str): return QFileInfo(fullFileName).fileName() if __name__ == "__main__": qapp = QApplication.instance() or QApplication([]) qapp.setAttribute(Qt.ApplicationAttribute.AA_DontShowIconsInMenus) main_win = MainWindow() main_win.show() qapp.exec_() app-model-0.3.0/docs/000077500000000000000000000000001467241150700143275ustar00rootroot00000000000000app-model-0.3.0/docs/css/000077500000000000000000000000001467241150700151175ustar00rootroot00000000000000app-model-0.3.0/docs/css/style.css000066400000000000000000000113771467241150700170020ustar00rootroot00000000000000/* Increase logo size */ .md-header__button.md-logo { padding-bottom: 0.2rem; padding-right: 0; } .md-header__button.md-logo img { height: 1.5rem; } /* Mark external links as such (also in nav) */ a.external:hover::after, a.md-nav__link[href^="https:"]:hover::after { /* https://primer.style/octicons/link-external-16 */ background-image: url('data:image/svg+xml,'); height: 0.8em; width: 0.8em; margin-left: 0.2em; content: ' '; display: inline-block; } /* More space at the bottom of the page */ .md-main__inner { margin-bottom: 1.5rem; } /* ------------------------------- */ /* Indentation. */ div.doc-contents:not(.first) { padding-left: 25px; border-left: .05rem solid var(--md-typeset-table-color); } /* Mark external links as such. */ a.external::after, a.autorefs-external::after { /* https://primer.style/octicons/arrow-up-right-24 */ mask-image: url('data:image/svg+xml,'); -webkit-mask-image: url('data:image/svg+xml,'); content: ' '; display: inline-block; vertical-align: middle; position: relative; height: 1em; width: 1em; background-color: var(--md-typeset-a-color); } a.external:hover::after, a.autorefs-external:hover::after { background-color: var(--md-accent-fg-color); } /* Avoid breaking parameters name, etc. in table cells. */ td code { word-break: normal !important; } /* ------------------------------- */ /* Avoid breaking parameter names, etc. in table cells. */ .doc-contents td code { word-break: normal !important; } /* No line break before first paragraph of descriptions. */ .doc-md-description, .doc-md-description>p:first-child { display: inline; } /* Max width for docstring sections tables. */ .doc .md-typeset__table, .doc .md-typeset__table table { display: table !important; width: 100%; } .doc .md-typeset__table tr { display: table-row; } /* Defaults in Spacy table style. */ .doc-param-default { float: right; } /* Symbols in Navigation and ToC. */ :root, [data-md-color-scheme="default"] { --doc-symbol-attribute-fg-color: #953800; --doc-symbol-function-fg-color: #8250df; --doc-symbol-method-fg-color: #8250df; --doc-symbol-class-fg-color: #0550ae; --doc-symbol-module-fg-color: #5cad0f; --doc-symbol-attribute-bg-color: #9538001a; --doc-symbol-function-bg-color: #8250df1a; --doc-symbol-method-bg-color: #8250df1a; --doc-symbol-class-bg-color: #0550ae1a; --doc-symbol-module-bg-color: #5cad0f1a; } [data-md-color-scheme="slate"] { --doc-symbol-attribute-fg-color: #ffa657; --doc-symbol-function-fg-color: #d2a8ff; --doc-symbol-method-fg-color: #d2a8ff; --doc-symbol-class-fg-color: #79c0ff; --doc-symbol-module-fg-color: #baff79; --doc-symbol-attribute-bg-color: #ffa6571a; --doc-symbol-function-bg-color: #d2a8ff1a; --doc-symbol-method-bg-color: #d2a8ff1a; --doc-symbol-class-bg-color: #79c0ff1a; --doc-symbol-module-bg-color: #baff791a; } code.doc-symbol { border-radius: .1rem; font-size: .85em; padding: 0 .3em; font-weight: bold; } code.doc-symbol-attribute { color: var(--doc-symbol-attribute-fg-color); background-color: var(--doc-symbol-attribute-bg-color); } code.doc-symbol-attribute::after { content: "attr"; } code.doc-symbol-function { color: var(--doc-symbol-function-fg-color); background-color: var(--doc-symbol-function-bg-color); } code.doc-symbol-function::after { content: "func"; } code.doc-symbol-method { color: var(--doc-symbol-method-fg-color); background-color: var(--doc-symbol-method-bg-color); } code.doc-symbol-method::after { content: "meth"; } code.doc-symbol-class { color: var(--doc-symbol-class-fg-color); background-color: var(--doc-symbol-class-bg-color); } code.doc-symbol-class::after { content: "class"; } code.doc-symbol-module { color: var(--doc-symbol-module-fg-color); background-color: var(--doc-symbol-module-bg-color); } code.doc-symbol-module::after { content: "mod"; }app-model-0.3.0/docs/gen_ref_nav.py000066400000000000000000000021631467241150700171540ustar00rootroot00000000000000"""Generate the code reference pages and navigation.""" from pathlib import Path import mkdocs_gen_files SRC = Path("src") PKG = SRC / "app_model" nav = mkdocs_gen_files.Nav() mod_symbol = '' for path in sorted(SRC.rglob("*.py")): module_path = path.relative_to("src").with_suffix("") doc_path = path.relative_to(PKG).with_suffix(".md") full_doc_path = Path("reference", doc_path) parts = tuple(module_path.parts) if parts[-1] == "__init__": parts = parts[:-1] doc_path = doc_path.with_name("index.md") full_doc_path = full_doc_path.with_name("index.md") if parts[-1].startswith("_"): continue nav_parts = [f"{mod_symbol} {part}" for part in parts] nav[tuple(nav_parts)] = doc_path.as_posix() with mkdocs_gen_files.open(full_doc_path, "w") as fd: ident = ".".join(parts) fd.write(f"::: {ident}") mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path) with mkdocs_gen_files.open("reference/SUMMARY.txt", "w") as nav_file: nav_file.writelines(nav.build_literate_nav()) app-model-0.3.0/docs/getting_started.md000066400000000000000000000216351467241150700200470ustar00rootroot00000000000000# Getting Started ## Creating an Application Typical usage will begin by creating a [`Application`][app_model.Application] object. ```python from app_model import Application my_app = Application('my-app') ``` ## Registering Actions Most applications will have some number of actions that can be invoked by the user. Actions are typically callable objects that perform some operation, such as "open a file", "save a file", "copy", "paste", etc. These actions will usually be exposed in the application's menus and toolbars, and will usually have associated keybindings. Sometimes actions hold state, such as "toggle word wrap" or "toggle line numbers". `app-model` provides a high level [`Action`][app_model.Action] object that comprises a pointer to a callable object, along with placement in menus, keybindings, and additional metadata like title, icons, tooltips, etc... ```python from app_model.types import Action, KeyBindingRule, KeyCode, KeyMod, MenuRule def open_file(): print('open file!') def close_window(): print('close window!') ACTIONS: list[Action] = [ Action( id='open', title="Open", icon="fa6-solid:folder-open", callback=open_file, menus=['File'], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyO)], ), Action( id='close', title="Close", icon="fa-solid:window-close", callback=close_window, menus=['File'], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyW)], ), # ... ] ``` Actions are registered with the application using the [`Application.register_action()`][app_model.Application.register_action] method. ```python for action in ACTIONS: my_app.register_action(action) ``` ## Registries The application maintains three internal registries. 1. `Application.commands` is an instance of [`CommandsRegistry`][app_model.registries.CommandsRegistry]. It maintains all of the commands (the actual callable object) that have been registered with the application. 2. `Application.menus` is an instance of [`MenusRegistry`][app_model.registries.MenusRegistry]. It maintains all of the menus and submenu items that have been registered with the application. 3. `Application.keybindings` is an instance of [`KeyBindingsRegistry`][app_model.registries.KeyBindingsRegistry]. It maintains an association between a [KeyBinding][app_model.types.KeyBinding] and a command id in the `CommandsRegistry`. !!! Note Calling [`Application.register_action`][app_model.Application.register_action] with a single [`Action`][app_model.Action] object is just a convenience around independently registering objects with each of the registries using: - [CommandsRegistry.register_command][app_model.registries.CommandsRegistry.register_command] - [MenusRegistry.append_menu_items][app_model.registries.MenusRegistry.append_menu_items] - [KeyBindingsRegistry.register_keybinding_rule][app_model.registries.KeyBindingsRegistry.register_keybinding_rule] ### Registry events Each of these registries has a signal that is emitted when a new item is added. - `CommandsRegistry.registered` is emitted with the new command id (`str`) whenever [`CommandsRegistry.register_command`][app_model.registries.CommandsRegistry.register_command] is called - `MenusRegistry.menus_changed` is emitted with the new menu ids (`set[str]`) whenever [`MenusRegistry.append_menu_items`][app_model.registries.MenusRegistry.append_menu_items] or if the menu items have been disposed. - `KeyBindingsRegistry.registered` is emitted (no arguments) whenever [`KeyBindingsRegistry.register_keybinding_rule`][app_model.registries.KeyBindingsRegistry.register_keybinding_rule] is called. You can connect callbacks to these events to handle them as needed. ```python @my_app.commands.registered.connect def on_command_registered(command_id: str): print(f'Command {command_id!r} registered!') my_app.commands.register_command('new-id', lambda: None, title='No-op') # Command 'new-id' registered! ``` ## Executing Commands Registered commands may be executed on-demand using [`execute_command`][app_model.registries.CommandsRegistry.execute_command] method on the command registry: ```python my_app.commands.execute_command('open') # prints "open file!" from the `open_file` function registered above. ``` ### Command Arguments and Dependency Injection The `execute_command` function does accept `*args` and `**kwargs` that will be passed to the command. However, very often in a GUI application you may wish to infer some of the arguments from the current state of the application. For example, if you have menu item linked to a "close window", you likely want to close the current window. For this, `app-model` uses a dependency injection pattern, provided by the [`in-n-out`](https://github.com/pyapp-kit/in-n-out) library. The application has a [`injection_store`][app_model.Application.injection_store] attribute that is an instance of an `in_n_out.Store`. A `Store` is a collection of: - **providers**: Functions that can be called to return an instance of a given type. These may be used to provide arguments to commands, based on the type annotations in the command function definition. - **processors**: Functions that accept an instance of a given type and do something with it. These are used to process the return value of the command function at execution time, based on command definition return type annotations. See [`in-n-out` getting started](https://ino.readthedocs.io/en/latest/getting_started/) for more details on the use of providers/processos in the `Store`. Here's a simple example. Let's say an application has a `User` object with a `name()` method: ```python class User: def name(self): return 'John Doe' ``` Assume the application has some way of retrieving the current user: ```python def get_current_user() -> User: # ... get the current user from somewhere return User() ``` We register this provider function with the application's injection store: ```python my_app.injection_store.register_provider(get_current_user) ``` Now commands may be defined that accept a `User` argument, and used for callbacks in actions registered with the application. ```python def print_user_name(user: User) -> None: print(f"Hi {user.name()}!") action = Action( id='greet', title="Greet Current User", callback=print_user_name, ) my_app.register_action(action) my_app.commands.execute_command('greet') # prints "Hi John Doe!" ``` ## Connecting a GUI framework Of course, most of this is useless without some way to connect the application to a GUI framework. The [`app_model.backends`][app_model.backends] module provides functions that map the `app-model` model onto various GUI framework models. !!! note "erm... someday 😂" Well, really it's just Qt for now, but the abstraction is valuable for the ability to swap backends. And we hope to add more backends if the demand is there. ### Qt Currently, we don't have a generic abstraction for the application window, so users are encouraged to directly use the classes in the `app_model.backends.qt` module. One of the main classes is the [`QModelMainWindow`][app_model.backends.qt.QModelMainWindow] object: a subclass of `QMainWindow` that knows how to map an `Application` object onto the Qt model. ```python from app_model.backends.qt import QModelMainWindow from qtpy.QtWidgets import QApplication app = QApplication([]) # create the main window with our app_model.Application main = QModelMainWindow(my_app) # pick menus for main menu bar, # using menu ids from the application's MenusRegistry main.setModelMenuBar(['File']) # add toolbars using menu ids from the application's MenusRegistry # here we re-use the File menu ... but you can have menus # dedicated for toolbars, or just exclude items from the menu main.addModelToolBar('File') main.show() app.exec_() ``` You should now have a QMainWindow with a menu bar and toolbar populated with the actions you registered with the application with icons, keybindings, and callbacks all connected. ![QMainWindow with menu bar and toolbar](images/qmainwindow.jpeg) Once objects have been registered with the application, it becomes very easy to create Qt objects (such as [`QMainWindow`](https://doc.qt.io/qt-6/qmainwindow.html), [`QMenu`](https://doc.qt.io/qt-6/qmenu.html), [`QMenuBar`](https://doc.qt.io/qt-6/qmenubar.html), [`QAction`](https://doc.qt.io/qt-6/qaction.html), [`QToolBar`](https://doc.qt.io/qt-6/qtoolbar.html), etc...) with very minimal boilerplate and repetitive procedural code. See all objects in the [Qt backend API docs][app_model.backends.qt]. !!! Tip Application registries are backed by [psygnal](https://github.com/pyapp-kit/psygnal), and emit events when modified. These events are connected to the Qt objects, so `QModel...` objects such as `QModelMenu` and `QCommandAction` will be updated when the application's registry is updated. app-model-0.3.0/docs/images/000077500000000000000000000000001467241150700155745ustar00rootroot00000000000000app-model-0.3.0/docs/images/qmainwindow.jpeg000066400000000000000000001276031467241150700210110ustar00rootroot00000000000000ÿØÿàJFIFHHÿáLExifMM*‡i& Ý Xÿí8Photoshop 3.08BIM8BIM%ÔŒÙ²é€ ˜ìøB~ÿâ ICC_PROFILE applmntrRGB XYZ ç acspAPPLAPPLöÖÓ-appldescPbdscm´êcprt #wtptÄrXYZØgXYZìbXYZrTRC aarg vcgt @0ndin p>mmod °(vcgp Ø8bTRC gTRC aabg aagg descDisplaymluc& hrHRØkoKRØnbNOØidØhuHUØcsCZØdaDKØnlNLØfiFIØitITØesESØroROØfrCAØarØukUAØheILØzhTWØviVNØskSKØzhCNØruRUØenGBØfrFRØmsØhiINØthTHØcaESØenAUØesXLØdeDEØenUSØptBRØplPLØelGRØsvSEØtrTRØptPTØjaJPØLG HDR 4KtextCopyright Apple Inc., 2023XYZ óR¾XYZ o¤8ö‘XYZ b”·†ÚXYZ $ž„¶Âcurv #(-26;@EJOTY^chmrw|†‹•šŸ£¨­²·¼ÁÆËÐÕÛàåëðöû %+28>ELRY`gnu|ƒ‹’š¡©±¹ÁÉÑÙáéòú &/8AKT]gqz„Ž˜¢¬¶ÁËÕàëõ !-8COZfr~Š–¢®ºÇÓàìù -;HUcq~Œš¨¶ÄÓáðþ +:IXgw†–¦µÅÕåö'7HYj{Œ¯ÀÑãõ+=Oat†™¬¿Òåø 2FZn‚–ª¾Òçû  % : O d y ¤ º Ï å û  ' = T j ˜ ® Å Ü ó " 9 Q i € ˜ ° È á ù  * C \ u Ž § À Ù ó & @ Z t Ž © Ã Þ ø.Id›¶Òî %A^z–³Ïì &Ca~›¹×õ1OmŒªÉè&Ed„£Ãã#Ccƒ¤Åå'Ij‹­Îð4Vx›½à&Il²ÖúAe‰®Ò÷@eНÕú Ek‘·Ý*QwžÅì;cвÚ*R{£ÌõGp™Ãì@j”¾é>i”¿ê  A l ˜ Ä ð!!H!u!¡!Î!û"'"U"‚"¯"Ý# #8#f#”#Â#ð$$M$|$«$Ú% %8%h%—%Ç%÷&'&W&‡&·&è''I'z'«'Ü( (?(q(¢(Ô))8)k))Ð**5*h*›*Ï++6+i++Ñ,,9,n,¢,×- -A-v-«-á..L.‚.·.î/$/Z/‘/Ç/þ050l0¤0Û11J1‚1º1ò2*2c2›2Ô3 3F33¸3ñ4+4e4ž4Ø55M5‡5Â5ý676r6®6é7$7`7œ7×88P8Œ8È99B99¼9ù:6:t:²:ï;-;k;ª;è<' >`> >à?!?a?¢?â@#@d@¦@çA)AjA¬AîB0BrBµB÷C:C}CÀDDGDŠDÎEEUEšEÞF"FgF«FðG5G{GÀHHKH‘H×IIcI©IðJ7J}JÄK KSKšKâL*LrLºMMJM“MÜN%NnN·OOIO“OÝP'PqP»QQPQ›QæR1R|RÇSS_SªSöTBTTÛU(UuUÂVV\V©V÷WDW’WàX/X}XËYYiY¸ZZVZ¦Zõ[E[•[å\5\†\Ö]']x]É^^l^½__a_³``W`ª`üaOa¢aõbIbœbðcCc—cëd@d”dée=e’eçf=f’fèg=g“géh?h–hìiCišiñjHjŸj÷kOk§kÿlWl¯mm`m¹nnknÄooxoÑp+p†pàq:q•qðrKr¦ss]s¸ttptÌu(u…uáv>v›vøwVw³xxnxÌy*y‰yçzFz¥{{c{Â|!||á}A}¡~~b~Â#„å€G€¨ kÍ‚0‚’‚ôƒWƒº„„€„ã…G…«††r†×‡;‡ŸˆˆiˆÎ‰3‰™‰þŠdŠÊ‹0‹–‹üŒcŒÊ1˜ÿŽfŽÎ6žnÖ‘?‘¨’’z’ã“M“¶” ”Š”ô•_•É–4–Ÿ— —u—à˜L˜¸™$™™üšhšÕ›B›¯œœ‰œ÷dÒž@ž®ŸŸ‹Ÿú i Ø¡G¡¶¢&¢–££v£æ¤V¤Ç¥8¥©¦¦‹¦ý§n§à¨R¨Ä©7©©ªª««u«é¬\¬Ð­D­¸®-®¡¯¯‹°°u°ê±`±Ö²K²Â³8³®´%´œµµŠ¶¶y¶ð·h·à¸Y¸Ñ¹J¹Âº;ºµ».»§¼!¼›½½¾ ¾„¾ÿ¿z¿õÀpÀìÁgÁãÂ_ÂÛÃXÃÔÄQÄÎÅKÅÈÆFÆÃÇAÇ¿È=ȼÉ:ɹÊ8Ê·Ë6˶Ì5̵Í5͵Î6ζÏ7ϸÐ9кÑ<ѾÒ?ÒÁÓDÓÆÔIÔËÕNÕÑÖUÖØ×\×àØdØèÙlÙñÚvÚûÛ€ÜÜŠÝÝ–ÞÞ¢ß)߯à6à½áDáÌâSâÛãcãëäsäü儿 æ–çç©è2è¼éFéÐê[êåëpëûì†ííœî(î´ï@ïÌðXðåñrñÿòŒóó§ô4ôÂõPõÞömöû÷Šøø¨ù8ùÇúWúçûwüü˜ý)ýºþKþÜÿmÿÿparaffò§ YÐ [vcgtndin6£×T{LÍ™š&f\PT;333333mmodmwõØ~‚€vcgpffffff334334334ÿÀXÝ"ÿÄ ÿĵ}!1AQa"q2‘¡#B±ÁRÑð$3br‚ %&'()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyzƒ„…†‡ˆ‰Š’“”•–—˜™š¢£¤¥¦§¨©ª²³´µ¶·¸¹ºÂÃÄÅÆÇÈÉÊÒÓÔÕÖרÙÚáâãäåæçèéêñòóôõö÷øùúÿÄ ÿĵw!1AQaq"2B‘¡±Á #3RðbrÑ $4á%ñ&'()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz‚ƒ„…†‡ˆ‰Š’“”•–—˜™š¢£¤¥¦§¨©ª²³´µ¶·¸¹ºÂÃÄÅÆÇÈÉÊÒÓÔÕÖרÙÚâãäåæçèéêòóôõö÷øùúÿÛC  ÿÛC  ÿÝ.ÿÚ ?ýü¢Š(¢Š(¢Š(˵]RâöáþvX•ˆU¹õ&²r}M©úÒV€.O©£'ÔÒW-ãèß¼!«øßÄ&A¦è–’^Ý{ùq ͵27tÕdúš2}M|)ü/à=ÄI4>)–7«¦–äzCàŠôßþÓú›ð_OøÙ០kþ!Óõ=@XE§ÛÛîÐùÒBòIÉU ó–*8ÝôöO©£'Ô׈xƒã†áøAs¥Þ5ÿŽ,î¯-îIEKqi ÌÉ2»qFpO5íÔ¹>¦ŒŸSIE.M9e’2”Žài”P¤xP–þÕ„ç2D@'Ô„ûñ]qžû·_XÿöjìêQHŠ( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( Š( ÿÐýü¢Š(¢Š(¢Ša4ãmÔÓr)ŒpÇ'œÓsÿ׿´@ø"¼ö«'þÃâ7ó.Ýÿè½ì7§éÍ|ÿûU6gˆßö/]ôÿpÐÉÿ?nOÙÓÀ¿¼àïk·pjš>mewØÜ¸IbŒ+¨tŒ«GPH5ëµ'ÇýoAýš,~1|ÕÍ‘ÕolMëÛFå­® n¨ÝT°ªî±^¯û.Cü3§Ã‚QI>²' O”¿Ÿë^ÿÇü3dʃ®X|ÍÚ€$øÀÙý»~œcþ%zßþ‘\Õ/ˆ?þ0üVøß¯üø3â;ÀG‚!‡û{ıAs4÷7* ·Šl®dm9V%€Ú­kâñ?ðÝ'þ%ZßNåÊæ¾høàoƒ_ ?ik¿´Ç„§Õ|ã¹"Ô´z/µ«m¦ sEñœ‘D—v›Õ)b‰B¬ŠÌHÀmmņÖ?pî¯ÎÙv×ö|ñÅ‹ï|ø_w¦èÚ=‹F¾4¹¸¼Šf“äkkkK†`ä«Xà€¿0&ïÑÞô.ìö£5ïò9¤ÝÓúPuáâïêŸû5v{‡¿—]Ÿ¯­xû7_rxÿ9¨‹_çÒ®êöW/ò3FÄ•ecÓŽ„w¬›˽hó ÿõša—üžk0Üw¦ûw  o0>?Îi<Þõ‘çGÚ1ßé@_òj9¼©ÐÅ2,ˆÜ2¸ ÔƒY_iïž?´sŠ×GHÔ$`*…Pt¥ó{Ö8¸'½hç  “/ù4†\ÿõù¬´÷ÏáNIFÛ–'Œ(É?•zW„¥»ÇL§ŸÖ»/4uÇøW  DöV¬ÓðòJçqßžk{ícÔzŸåPÀÛóGÿ¯ž¾ôy¿þ³Íb}¬uÎ׎)>Ô=GçH Ï8~ñþsK掣ÿ ÂûRã¨?¥/ÚǨôÿ?Ê€6üÑÿë篽oÿ¬óXŸksõãŠOµQùÐçœ?xÿ9¥óGQŒ…a}©qÔÒ—ícÔzŸå@~hÿõó×Þ7ÿÖy¬OµŽ¹ÀúñÅ'Ú‡¨üès·<œÒù£¨Æ?°¾Ô¸êéKö±ê=?Ïò  ¿4úùëïG›ÿë<Ö'ÚÇ\à}xâ“í@ÿ$ûæ€7DçAÏç4¢a׌~9â°¾Ô=súS…ÐõéÏóÒ€7<ÞÇßÞž$ÿõžÏ¥a‹ sùž8©Öu=Á'üúÐÈ|ñÛž?ÎjPÜvÇãÚ²–`G¯éüªÒIïíõÿ=(ð öÿ?Zp8útªêÿ‘ü¸©”þt5Õ=©ÔQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEÿÒýü¢ŠBp(2rOãQ±þ—ô§±íßÞ sŒãùÐNýsÇóŠ¡,»A=‡>ŸZ’V>ÿ‡?•rÚýëZéww(~hâf\z€MR–ö÷WšHì$û5¤LciÀ‡ ±ƒÀ r @<y ÌßIs+¬g‘IüªþB¯Y[ +8,פ1„'ÔÉ8îO'Þ­P7öš{\gþ¾n?øå7þí3Òãÿn?øåmÑ@ðŽi_ÝŸÿn?øåðŽéݸÿÀ›þ9[”Pü#º_¥ÇþÜñÊ?áÒºbàÛÍÇÿ­Ê(þÍ+û³ÿàMÇÿ£þÝ/û·øqÿÇ+rŠÃÿ„wKô¸ÿÀ›þ9Gü#ºWL\ûy¸ÿ㕹EaÿÂ9¥vü ¸ÿã”Â;¥ÿvãÿn?øånQ@ðŽé~—øqÿÇ(ÿ„wJé‹ÿo7ür·(  ?øG4¯îÏÿ7ürøGt¿îÜàMÇÿ­Ê(þÝ/Òãÿn?øåðŽé]1p?íæãÿŽVå‡ÿæ•ýÙÿð&ãÿŽQÿî—ýÛü ¸ÿ㕹EaÿÂ;¥ú\àMÇÿ£þÝ+¦.ý¼ÜñÊÜ¢€0ÿáÒ¿»?þÜñÊ?áÒÿ»qÿ7ür·(  ?øGt¿Kü ¸ÿã”Â;¥tÅÀÿ·›þ9[”Pü#šW÷gÿÀ›þ9Kÿî—éqÏý<ÜñÊÛ¢€1áÓGkü ¸ÿã”ïì-?)¸Ô\NBä~b¶(  3¥¤>Òi/`@KÁ. €;£7ýÒ =ŽpYc}ä s†ŽEXt ÿ*Ϭm¾Íqb¤‚äìPp‘M vvzr~©ÿ"­©ã¾?.k'*ѳƒ@:ýGéRç5ç€xö5*úP¨¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢ŠÿÓýü¦·¥:˜qŸzaïÇJ§+qÛj´ÝÿÏOò*„Çü>´pÜÿ]pþ,—þ$Wÿõîýr„×atøöþx®Å­üwû;ÿè&€;CŒœRRž´”WÈ_¶ísá¯Ù'ÁšV¿ªé’kš®½töºn¢âVšFr¯…Œ2dc’Àdf¾½¯Áÿø-ŸüÑŸû˜¿÷@µÿ‚Ò§Ú"ûgÃ#äog—¨a¶÷ÚLDgð¯Ø„üñ¯ÀZgÄ?jpÜiÚ”aŠ—Q$2€7Ã*ç*èx à×òsðÿö7ý¡¾%ø^ÏÆ^ð¥ÌºN  ZÜL bd®ôÈù”@#Œƒ^ƒgûþ×vïìÿ\ÚïÆï&vLã¦vã8 ëV+‹yø‚TŽHR?#SWó§û~Ïß¶GÁ¿ÚÂ~&Õ¡Ô,¼8÷ÂÛ]I.à’ÊU)'™­·!”‘•eEEÍ À´.²pJF ’Š+ó¿ã‡Å?Œ_þ=§ì·ðWe’j-ñ2¢É5¬R*¸ŽT…’?¸D#… Š®ÄôBŠüÇøðGöšøá{¯Šß >1øÆ·:fûSмLÍwÕ´<æ%’V UA%W+ެ7Ôý©þßüð÷ǯ_`hÚÈKiÁŠ{“év†H6ÛÇ#%Àb Ž3ŠúbŠùRÿöÛý—ôÏ¿‚oý°?gOŠ>,Oø+Æ0^k33-¼[ÜÛ ÊŒ°…î"±œ(;ˆ¨ f¾• Š( Š( Š+ƒø‹¨êZg‡|í:W·qW‚^([;ÝqÎAÀÈçž9 òŠðï xÏKÑu›Ý2ó_{ý/ÊIm®nË;‡8 »±’9Ï#wÏ,Þ,Ôo<ÍrË\¸mYõ*ÓJ‹q…á,©Lmäw<œzœÐÓTP2q‘ƒ^E¬_ë¾.ñTþÐïN°Ó”Û˜²$g?À¬#®ã£ ]¢¼oWðv¹ák)5¿ ëWÓËh¦imîßÌIT ¿Êã'èA¯HðÖ·ˆ´+Mb!·í –_®°`qê9  Ê(¢€ æì$Ƶ«ŒŽ."õÿŸx«¤®Bѱ¯jãõñ~9·Š€;Ëy8Ïóçõ­ˆŽqïÀÎMs–¯Ðÿ>œVì>OC@kÓ¹ãüæ§íÓ¥VCÇŸOåSŽÙúP´QEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEÿÔýü¦6}éôÃÖ€ |`¯ùÅgÍß?Cþ{V„Ÿ®zú«6n‡Ûßüþt‰tx=_óŠóï1ÿ\}ý»﮳ƒÔþ½}+Ï<]Ή~GüðnÆ€=Œõ¤¥=i(¯Ê/ø(§ÁëŸ>xSY”ÚxrÉ”­_›?o-|3û{üoðμR-O_´³Ô4¶”€ÒÀ‰Ž‘düß,ŠH FÙû­ÒjùWöƒý–4?š¶‘ã­\¼ð_<<é¾!Ó†ç 2Ç4{º«1 ‡V˜T• oø¡â-ÂÏý“wc­Øl .¿<÷]Pl¸ÈbNã€nü}ð}çŠþ(þÉ>ø³f—7òÅršõ¤®³¤·0A`ó£°,²+J„7$0'’ zYýŒþ0xƒJ¶ðÄO:Ö·àKWˆ6•„v×WÄAH¥½ó¤‘‚í_¾$Œ…{ïÿgm?Å?þøÛIÕFeð¯í)m¦-±˜\Å0Ñì-ìµ}7â—emuoFéï”Ç•å ½'?£Uà´ÀÏø^ºo„ôïí¿ìOøEüMiâ-ÿfûOŸöT‘|Œy°ìÝæg~[n>éÏÿ@Q@Q@`x“^²ðö˜×רÓq pÆ2Ò»çã}ëÒ·ëŸñ/‡­üK¦ý‚y^IxfïG"gk^¤}Py µÔ®WÆ—·ÂŽìÖH¦Ë ”‡ïyéÓ4¾ñ=¿‡ôö¸ÔticµkéµDEØ À /Cƒô«Õ¼7áGÑ.îµMBþ]OP»UGžE'EUãߞåsóü3Šk™a]Râ=âçíréÁFÒä† >r8ÇãžhÔ+É<"YøãÅšmÁÅÄó­Ì`ðZ=ÌÜ{ãƽo¸ø&ß_»ƒV³º“LÕ-ÀÝB2Há—+ž§œƒƒƒ‘ÅtZåݽ†{yt@Š(›wCòœÄðrkŽøSo5¿‚l̹kË"†ã \ød}k>O‡Úæ²ñÅâ¿K}göhãÇM̧ÿe'ЃÍz¼ÚÁµºâ‰B"ŽU{@ÑEWnâ¡ÖFåâ/ý&Š»zá`8ñ³ÿ]âïÿNÑPmhzvïþq] ÇŸJæ¬ú g±ãž¾•ÐÛö"€5£Æ1é‘þGÿZ¬ðÿ>ÕZ,ñüÿ[ÿ¯V‡  (¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢Š(¢ŠÿÕýü¦þ°§Ó3ï@?OÏŠÌœðz`}{VœœƒY³ƒý>´wѳþ=kÏ<_Ɖ~éƒõ籯Cº<ÿêâ¼ïÆþÄ¿õòÿA4è‡8¤¥=i(ª×vVWñyðGsàÛ%EuÈèpÀŒÕš(þ Ð&ÇþüGÿÄÑÿdž¿ècÿ~#ÿâkrŠ¡g¤éZs³éöp[3¬ÐƈH‰P3W袀 (¢€ ÍÕtm]µû·cm¨[ny7Q$©¹~ëlpW#±ÆEiQ@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@pŸø¨µžGúø¿ôš*îë‚„ÿÅG¬úùñןôh¨´´è3þ=k¢ƒ§×ךçmqϧé]àw,ºð/ÄãA×,•Z[[¤íq”ttfI‡FFe8 ƒ_¸ðE¿ø‡Rð÷Å? _ßÏq¤èóè×6²;4pIv·ËpcS¡Ää W=kóööý¦¼CûG|c•5=O iž{'MÓ¯aê<Áç=ó¸JÌ ùY+ùW$³·ßðDÏù¬ß÷.ÿîN€?x+Ä~5þÐß ~iÚ§Ä]LÁ5ù+c§Ú¡žîä®7yQ)*äîU €}º¿3>èv_oÿ‰~/ñlKÃK;=?B·œå-¥tJ¨Ç« œa^MÃæ @±x/öñøâ¿ÙxOZ‡]ðV¡©?—d,ý°>;øVãã?…>'x{À¶WòO7‡¼'%µÀx"vHÒêæU.­!R ÿxì?Ù_ã”ß´Â/ê6‘Ùjö×2éz´bvÁK¼g$„uep %s·,âômQ@Q@cëºîŸáÝ=õ=IȉHUTwc÷UGrúçŠØ®gÅ~$ÓÍ'6ÓÁ2\ÛÌà’¦v’§¨Á#õ øwźw‰xm£¸¶¹µÇ›ot›$Pßu±’0~¿\dV<ß|;o©=ƒ­ÉŠ9þÏ%è6É/M¥óž=vã¿Nk„´±ñÁñÕüjvƒP1™f1e=À*…Ç 2j§„¼-­ø—D–ÆëPŽ-$êפy’GF°ç b==(ܵ­VJ¸Õ®#’X­”;¬@# g'ž‚¥ÓumWM¶Õ-‰ÜIJ®ì á°Ht<õ©/¬¢¿°¸Ó廸‰áaìêTþ†¼SD×¥Ò¾êvó·Zd“iàgîØR?Ý.qþíz_…üc¥xµ.›MYSìŽÄ¡A!³µ†ÒܸÿJBÞßäPÿÇ¥'OåMÎ=9ý?ÖŒŸçÿë óþ•Í7$ûý) {‘@ü”ý?•78ôçõüÿZ2Ÿÿ®€ÏùþTc4Ü“ïô¤-íþE?ñüzRwôþTÜãÓŸ×óýhÉþþº?çùQŒÓrO¿Ò··ùÿÇñéIßÓùSsN_Ïõ£'ùÿúèüÿŸåF3M Ÿ§j {‘@ü”ý?•7?¯ëùþ´g®=ÿýtþÏò£¤ÝŸþ·8¤'òÇò Ï¿ãÒ—“ßÜÏ׿·?­.Gcþ}hr•;q¦Ò‡ò î¹ùúûRóþIE3>Ÿ¥.êu™´QEQEQEÿ×ýü¦þ°§Ó3ï@?OÏŠÌŸ¡éǵiIОߕfÏ“È#œÿŸóšçîÏÞãׯ5ç¾.çD¿ÎÔ8ç§ó¯DºÇ'ŒãÛ°éÅyÿ‹W%øÆÙߢž”è8¤¥=i(¯Áÿø-ŸüÑŸû˜¿÷_¼ñí—ûx{ö¿ð÷‡ì/üA?†uo Ïq%…üvëw—v±‹ˆä·2BX1Š2H •î ~Á=hO„?³¿Æ;|\ÑDöÚ…ŸÙlõõç›H˜»¤²Î¤#²*€ü¬àýmÿ|ñOü4?öçöGüZ?ùÿcìO·ù;óý£æÿÏÏ'w•³äÎÿÞ×_ÿLÿªÍÿ–ïÿ|èÿ‡&ÕfÿËwÿ¾tðÏü/ö„øCûD|b¶ñ'Â=`¶ÓìþËy¯¼oÚ»ü¥àp¥V4gQ#C|ªŠ¿sÁ?æ³Ü»ÿ¹:?áÉŸõY¿òÝÿï~ˆ~ÆŸ±§‡¿dx‚ÂÃÄø›Vñ4öò_ßÉn¶‘ùv‹ ·Ž;q$ÅB™d%Œ„±nÀPÚù{ñNóÄ?²OíU©þÐSè×z¯Ãoˆ6PÙëóØ£JúuÌJˆ$ec-pX€ÂI~ePP©®‹"”pXeaAêî ~t|Aý½|=âÝ>/~ÊV—Þ8ñî®é’ý‚xímFå2Iqö•„«ú°~f`«†gí ¿ÇÙ>Ï Þ²šŽ¢º„öé²'¹XyÍsµ î* $.2kô:ÃLÓt´h´ËH-Éf[tXÁ'©!ûÕêüÊñ¿ŠÇìû\xŸâçŽ´ëÆø{ñ7Lµµ{8Úu²¾³Ž8öLŠ Û rÁÁ]ÛYV¶ãk_Ûö§øâ߆ö’øáJÞ^Þk—Q=²O{vŠ#†u Ä|nð‡/µÉþk¶ré^¸ñ&õ—tÐO¼%€"c@Iå æ ý[šnÊT$8dr¡©(ðgá£þÅ?<Þý¦¾ßi_¼8ÓZê02j2¾ êìc’Ý ˜D<Å ”Œ‘•b¤ýCýô/i ûÂß®>Xk7ÒßG£ÝÝÏw<ˆQ#K™ZçFÒªçåUlÕôëÛ[Ë*O$hòEŽ@%s×i##𩨢Š(¢Š(® â<šœ~-¦™•<øÅÙ·ÿZ-ùÞSç8϶sÆk½¢€>yð¿„të·×v"ûû(Q"ÝÑÞS‚ØcÉg#·ÕÍÁdÚɦiãP‡Äðê· ž"à ßÂT®r{÷ã5õUsÆz÷¯µ­"åþ"·†UеkÈ59¨\Éí†;³î}EyoÅ8î,ôÛXŒ\iK '¦É0¬±` ûÚøs¦/Áöà‰.PÜÈORe;†}öà~ÜQ@Q@yò|I¬ôâx¿/³EþzW ×Ÿ ÿŠ“YîLñÿ€ÑPahOמ½ºrØéŽÃ<óýk›´*0HééÓzß„‚OCÇ¥lÄÄñ޹ä~5iXñÓŽ=1ÇùíYèF9¹Ï>ê¶qëÓÔñ@6Ï_ëFOÓëþyªû‡§ùô£8ìGéÒ€,Ïolþ4dÿž;žÕGÄœ ÿW=(pÍŸó×úÑ“ôúÿžj¾áéþ}(Î;út  ³ÛÛ??çŽßçµA‘ßñ'€ÃÿÕÏJœ3güõþ´dý>¿çš¯¸zŸJ3ŽÄ~(ÁlööÏãFOùã·ùíPdwüIÀ 0ÿõsÒ€' Ùÿ=­?O¯ùæ«îŸçҌ㱧J°[=½³øÑ“þxíþ{Tÿp( ?ý\ô  Ã6Ï_ëFOÓëþyªû‡§ùô£8ìGéÒ€,Ïolþ4dÿž;žÕGÄœ ÿW=(pÍŸó×úÑ“ôúÿžj¾áéþ}(Î;út  ³ÛÛ??çŽßçµA‘ßñ'€ÃÿÕÏJœ3güõþ´dý>¿çš¯¸zŸJ3ŽÄ~(ÁlööÏãFOùã·ùíPnõüsŽ3øsÓüûÐàŸóëFìqŒ}Ï5ú7{‘útüèrÞÙí×Þßç§oóÚ Ýê< PÝþ½?Ï¥Lüã¿õ¥ã×_óÍC»žƒéJÈ#ôé@uõúþ4dý?ÏùíP‚·©9ÏçŠvîžôê(¢€ (¢€?ÿÐýü¨ÛñÇéO'Z‰†yÿ8õ  ïÎ~¿äcóëTfç¸ïŸOÓ¢þœúã¯ùýj”Ã{^?_Æ€0nçw~}{õ«ˆñ«Üé—Vè2DÊ002AÿŸþ¿}:žxÎqÇ\ç°Ïëõ®zê sëë߯cN¼MBÂÞùÄñ«;9ЃGb*åyô7þšO³Dn¬%bínY£Î†êTãžA9Ô6ÐUsp×0ÕZÞbGâŠËùëh®<øóÂëÖâü¹ÿãUøƒáOùùŸÿ.¿øÕv”Wÿ Â]>Õ>}>ÉuÿÆ©§â7„Ò§ãþ.¿øÍvôWÿ Âóõ?þ]ñšAñÁý®çÿÀK¯þ5@ÅÃÿÂÇðüýÏŸO²]ý£H~#øŸdº?ûFøY>ÿLŸútºÿã4ÜÑ\7ü,ÿÏäÿø uÿÆiÄù|Ÿ×þ=.¿øÕwTW ÿ #Áçþ^çϧÙ.³Ïý±¥ÿ…àþÒçãþ.¿øÍwW>!øHž.§?öésÿÆ©ãÇÞaòO;Ÿî‹[ Oç­vUæö­æ«ª_&9AX‘bϦÌ^(¿ñ=ö³²Ñà–Î'âK©€`õ¢“‚Gñ‘Ùs‚-é¶ÑZÀ–ñ.ÔB¨ÏN3øûÐOlq‚HúñϯëÅmÂqÛhãØgüÿŸ\+sÓßùÏaßëZñ0À#¯<óùóþMk£Óg¿èçó©ÃsÏLý?Lt¬õ~ƒž£Ž¿çõ©CúžÞ½1Žè@¸öïéI¸}?—ùôª†L~?¯çúÑ¿ß×ÿ×@÷Òï_ǵSó=?!Û4žgú*º\{wô¤Ü>ŸËüúUC&?×óýhßïëÿë  {éw¯ãÚ©ùžŸíšO3Ž}?•].=»úRnOåþ}*¡“ëùþ´o÷õÿõнô»Ç×ñíTüÏOÈvÍ'™Ç>ŸÊ€.—Ýý)7§òÿ>•PÉÇõüÿZ7ûúÿúèÞú]ãëøöª~g§ä;f“ÌãŸOå@Knþ”›‡ÓùŸJ¨dÇãúþ­ýýýto}.ñõü{U?3Óò³Iæqϧò  ¥Ç·JMÃéü¿Ï¥T2cñý?Öþþ¿þº·¾—xúþ=ªŸ™éùÙ¤ó8çÓùPÝürGz7~ËŸóÅS2cñý?Öþþ¿þº¹¿ÓŠ7þ?øÕ1'§ä9Æhó8çÓù~s‘Þ»°ãùsT|ϧ?¯çúÒï÷õÿõÐÝÿ‡ùéFîÝj “=?Oz7ñϧj¹»ŽqÞžê?—?犥æ~$þ¿ŸëNßè}ý|ÐÀþŸçÚœ'×ü?ªòôõ¥ þ{P½ÇAëÖœéÓ?—?犬?ëùΜ<ž¿þº²ÿõ¿¥8ûûT³ÓòíßÞÿëPÞùüiÜoåQn#ñõïíÏëNëÐúÿúè^Ïò£¦ƒÇ¯Óµ8çІõ¤tÿ=«²šs‘îs€};V\°gß u?çõ 2[N§Ôg¯ùÿ9¢ö™ê=†yçú×_%¿Ç_§§N+Ÿ½ºŠÞqgostã+C.@8Éì£=K=è­¦zãÿÕÏ­BÖž€céÛçµkÿeøšã RÒÑHΙܼBä{)ãúéûÚ¸=0-˜þ¾pïí@FÔz^~´Æµ˜íÏ9ÿß>Öÿè'1ÞÔöíÄÞ´ŸðŒëôƒÓ‹VÏŸ¾4Ï›Qœc®i†Ôz˶?ÏjèáÖÿè)©ÿD#§ÒjOøEõÎÚ¤·üz7óó½~´Ï›_öý}zÒ}—¶=†yçóæºøEõ¿ú @?çÐôôâjOøEu¾ŸÚzqhsǧïsßeÏã‘þG4}•}=ÇN?ÏjèO…5¿ú AêÑéô›µ'ü"šçý`ÏOøôcúùÔÏý” œ^zõÿ?â}”z{sÏ?ÖºøEuÏú Ûãóèzzq7­/ü"ºØ8þÕƒÓ‹FÏŸ¾4Ï}˜õ†Üvü8ÿ=«¡ÿ„S[ÿ ¬¿üzÓé5ðŠkŸôƒ=?ãÑëçP?öp3•þ¼õëIöaÜuãžyþµÐÿÂ+®ÐVßÿŸCÓÓ‰½iáÖÁÇö¬œZ6xôýñ wìÀñŽ9¿úô}™{þžÕÐÿÂ)­ÿÐVßþ=éôšøE5Ïú ÁžŸñèÇõó¨Ÿû8Êÿ^zõ¤û0î:ñÏ<ÿZèá×?è+oŒÏ¡ééÄÞ´¿ðŠë`ãûVN-̽€ÿ?ÏjèáÖÿè+ïÿ„túMGü"šçý`ÏOøôcúùÔÏýœ å¯=zÒ}˜tÇ·<óýk¡ÿ„W\ÿ ­¾1ÿ>‡§§zÒÿÂ+­ƒíX=8´lñéûã@ø¶1ê;õýêE¶Î?Çùí[ŸðŠkƒþb°ûtaÓé5=|+®ƒÿ![NmùùÞ´’–üýßëÏ^µ~(: {sÏÿ®®¯…µÁÿ1X#½¡è;q7­ZÂÚâ‘ÿX=?ãѳǧïÍG€3œŽ1ü¹­ˆéõ>˜ÿ=ª¬~×x?ÚÐ_ôF>“V„~ׯ?âoožƒý¿ŸŸ@P¶?ÎyúÿŸñÑGéÁô¹çúÖRxs^óƒñ´nÝ¿×Õµðæ¼1ÿx=8´lñÜ~þ€4ÖOËŸOåùÔ‚oO§ÓóÚ³G‡uüÈ^Üžÿèl:}'íRÂ;¯ãþCãþÝþ¾}hù§üúÿZO7ê>¸¬óáíþ‚öÿøß—úú_øGµàxÖ ü-tÿ¶ôÍüýtyßÏé×üúUøGµïú @íÍ¿øýÃÚþ?ä1ÿ·F?¯Ÿ@iÿ>¿Ö“Íú®+<ø{_ÿ ½¿þ·åþ¾—þíx5ˆ? F?í½_óÿ]wóúuÿ>•Cþí{þƒûsoþ?@ðö¿ù @?íÑëçЇšϯõ¤ó~£ëŠÏ>×ÿè/oÿ€mù¯¥ÿ„{^bÂчOûo@üßÇÿ×GüþÏ¥Pÿ„{^ÿ ÄþÜÛÿÐ<=¯ãþCûtcúùô¡æŸóëýi<ߨúâ³Ï‡µÿú Ûÿà~_ëéá×ãXƒð´aÓþÛÐÿ7ñÿõÑç?§_óéT?á׿è1ÿ·6ÿãôkøÿÄþÝþ¾}hy§üúÿZO7ê>¸¬óáíþ‚öÿøß—úú_øGµàxÖ ü-tÿ¶ôÍüýtyßÏé×üúUøGµïú @íÍ¿øýÃÚþ?ä1ÿ·F?¯Ÿ@iÿ>¿Ö“Íú®+<ø{_ÿ ½¿þ·åþ¾—þíx5ˆ? F?í½_óÿ]/š=q“ߎ¿çÒ³ÿá׿è1ÿ·6ÿãôkøÿÄþÝþ¾}h qþzš<Ð_ÏüóYÇÃÚÿýíÿð ¿/õô¿ðkÀñ¬AøZ0éÿmèCÍÏ¿ãš<Ïß§ùü«?þí{þƒûsoþ?@ðö¿ÿA‹üoççДç¿ÿ^7Ô‘õçÿ×Yßðëý?µíÿð ¿øý/ö¼:jð~Œ?ö½iy¹÷üsNþý?ÏåYØöä/níÑ¿øý/ö¾ü…àöèßÏÏ  U—;ÿ?ëN{‘þ?Ö²¿°µñÿ1{sÿnù¯§ ^óƒõèçý·  q&}ýÿIüÿÏùÅb^ó·?öèßü~šÚg‰£ÿU{g6;4R'êÿ‘ …\ÿŸ_ëO Ûùÿžk•“PÕt±»Y³9¸¶o5c?0Àe¹+ë[v÷QOË F†SÁÝEjÏ©ÿõÓÃgóïÆ2?Ïj¦üž*eoò9éþ}èÚ±ÿ>´ðqê>¿çš¬>™ÿ¼ÓAíÇÒÏ@1út ÚQñÎx÷v ¨îO—´É¾¯€Ãs w6î²Å*‡GBenC)‚1ÖŒaG›¡$íýl÷ ãòiªY•ö¾ßzÑ™_eçÚ²ŽxãÊ9àsŒñZñªKÉ+£€ÊT‚# ŒpAçš“Éùuÿ?}äd¤¹¢|ô£mˆ-°zsôõúQölsŒ¥kLÐÛB÷,QD¥äw *¨f$àÉ'ŒWÉ>+ý­¿çð¾H˯ùüè[`ôçéëô£ìØçJÛò{zÒÈ  _²Ž€óíGÙG<qž+kÉõÿ?€¥ò@þ]Ïç@‚Û§?O_¥fÀéÖ¶üžÀÞ´‚ò(ìÞßçéN[aÏqÚ¶|¯ÿWÿXRˆzÿŸóõ  •· œŒþ{TÉpáÍi»`ÿ:rÅß•RH@íÓüôëúU¤ŒtúŸjS?óþ5(\túPqÉã×ñ©@ö8ü©Bãü篥8 ý(QžiôQ@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@Q@q¶£Ãשuh6i×råˆ ,R· ëØ+è1÷Ž;zÇñ¨¼Ñ/àÆXÛ¹OgU,Œ=ÃGÒ€_Ò¬+gßü?çô»¿µØ[ÜÏHÕ¸÷­d~ݨE[ÔŽõ8?‡òæ¨+ú÷ý?Ö¬+{úÿ.½¨âžý¥Hzsíü꺶úÞõ ¿þº?çùQÏ®(ýE!8ïŠÿÓýõçóþÖ£?ˆúÿžiÇðÏOzŒœ{~?:cøÎxëP;?§_óéOcþ¿6:~œô ÜóþzÕ78ÿëóÏõ©ºôúJ©#cÛôéé@;dtõçóªR8õã8϶?ÏjžGëŸÌñTÇãÓŽx‡×ˆuV<í¶µ¿V˜õü;Wm·ÛÛœšâ<,CxƒX>–öŸúþ•Þíòíiâ‹Í7ÃZO„-ËMzižëí킃Œ3È™®Gzø+𯾿k_ ]ê>Ò|]j›ÓA–dºÆ~K{¤ã²¼iž€.Ojø¯Zü³‹\þ¶ùö²±ý¹àRÃÿaÃØ[Ÿš\Ýï}? ŽÞ½yë_{þÉ^(»Ô¼7«xFéüÄÐe…írIÙor¬}z+Æø.A_?}ïû%xNïNðÞ­âë´Øšô°¥®Aí탄“· ò>à®z8KŸëk“k;ÿ^¡ã¯Õÿ°çííÏÍ^÷¾¶ù\úÈ'·¯?Ƽ_ã.©,:}†ƒ¢ò=Àç-nN½ :äsï^ݶ¼[ã.‘,º}†¿Êi¯$sž~X¦ —úEÉ<I=+ÕñCëØ8¿©ß›—¦ö¾¿…Ïä^öÚ}·ÃÓOÄùñ²OOÖ…È==¹æ¥*Aë@\šÿ=ùèþSÞ¾ jrͧßèɦºIn?»ÀíN§€ÈøôÕìûxàq^;ðgH–>ÿ^‘pš“Çœ´P†Ãöà³¶1Áõíy÷¯ô'Âÿ¬`á>»~~^»Úú~?›ø«ÙÿhVö ÿMãÏÚïÅWzg†4ŸÚ?–¾ šg»Á?=µ²¡húŽäŒç*èkóõ²Iôé_ ÿµï„®õ/ é>1´C"hMÞ%-î•ÉÇexÓ$ã “Ú¿>Ù0}«âøïÚ}}óü6VýøƒÆ7_ûf~Þü¼«—µ­­¾w§ãž+ôöDñUÞ§á[Á÷oæ'‡å…í2I)mr®R>Iá^7 E tùþ¨Iö¯ÐOÙÂWšo†u]'–šü°¥¦Aù­íC„“· ò>Ò2 à÷£=§××'ÃgÓñÝí˜{òÙóvµ´ül}q´zz÷ÏZRžÞÜóRíç­.Ñ_¸Ÿ×Ä=½xéþ4mãÅM¶¼ûÐ;G§¯|õ¥)ííÏ5.ÞzÒíOo^:xàqSm£o>ôÑéëß=iJ{{sÍK·ž´»E@Û׎ŸãFßAÇãSm£o>ôÁéúç­8/¯ÓžiàRíQŒc׊pÇJ~1E7¿øÓ¨¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ k(u(Ý?CN¢€<ÓÃîÐl ç6éߨWLŸÏùÿŸJâ|-'üH4üñþŽ™Îð×WôÇøô÷  ˆØçüõþµež>¾¿Ö³³éô«ˆß‡éÒ€4çüsžõ:ŸçþÎ*’^=Iãüš²„ÇŽ9é@“?çÖ¤ù_óÍ@Ó=1R©Ç·éÒ€&_J?Ïùæ˜8?ÔñRq×ðçÚ€?ÿÔýòl÷#ÿ¯P±ëÛ?—4òp©ý?Ö¡cúgÿ×Ú€#cŽœUg9ÏðïS1ÿ#Þ©»qíË ·©ÿÏøU) öþ\Ôò?Sëúþ¯Ö³å~¼úÿúè _+>WëßüóSJùéùzËšNzc¿j¿á-¯ë<äyŸúõè•æž mÚþ·ÿ^ö_úÅz]Cq7PImr‹,R©GG«)*Aà‚8 ðE|›â¯Ù'Ãz•Ü—^Õ¥ÐOùux…ͺrOîмn£°fÐW×W&//£ŠJ5âšGÐd}»êÈn ‚ê -®QeŠU)$nVR0ÀƒÁpAàŠùÅŸ²†u;·»ðv¯6€ŽIû#·VèI'÷h^7QØ ûG`+ëú+‡•añ±PÅA4¾ÿ¼ñ3®ÁfñTó Jim}ùŸ xOöBðΙv—~1Õæ×Ñ?dHVÖÝÈ þñÈì;¿iî }so°Gml‹Q(Hã@ª…€TÔQ€Ê°ø(¸a`’{÷ûÃ%á¼QO/¤ žöÕ¿˜QEÞ{AEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPGZ(?•x·…¥ÿ‰‡ý{§þ‚8®²'Ï¿ùÿëW ái?âCaßý:sŒ­u±IÀÿ8  Èß’*ümøtúsþx¬xŸõý?Ö´#l÷õÿõФmþ¥ZSϯ­P‰ÿÎ*Ú9銸§¦Hç"§_NŸËŸóÅVSúþ¿ŸëS¯óÏÿ¯µMþúÕ ŽOJyÈþ\ã4½8$ñøÐÿÕýï9Ïùëýj»;¯ùæ¥ný3ŒT p};ztü軜ûçüjœ„}9þŸçµY~3ŸÌàU)§éÏOóï@db3þyþµ3zäóÍ[•ºôÍfL߇éÓó  s6sß·\ÖTÒuç¿Ó¯ùô«s·\ñõãüšÈ™úãôç¥Má]BâÏ^ÖLâ}ðZnËìÆöNsŸn•Þÿnê?ô_ûþ?øšóO ¿üOu“Çü{Ú9ý+¼óùÿõPÿíÝGþëÿÇÿGöî£ÿ@õÿ¿ãÿ‰ª`ÿ?þª<ÁþýTûwQÿ zÿßñÿÄÑý»¨ÿÐ=ïøÿâj‡˜?Ïÿª0ŸÿU_þÝÔè¿÷üñ4nê?ô_ûþ?øš¡æóÿê£ÌçÿÕ@ÿ·uú¯ýÿüMÛºý×þÿþ&¨yƒüÿú¨óùÿõPÿíÝGþëÿÇÿGöî£ÿ@õÿ¿ãÿ‰ª`ÿ?þª<ÁþýTûwQÿ zÿßñÿÄÑý»¨ÿÐ=ïøÿâj‡˜?Ïÿª0ŸÿU_þÝÔè¿÷üñ4nê?ô_ûþ?øš¡æóÿê£ÌçÿÕ@ÿ·uú¯ýÿüMÛºý×þÿþ&¨yƒüÿú¨óùÿõPÿíÝGþëÿÇÿGöî£ÿ@õÿ¿ãÿ‰ª`ÿ?þª<ÁþýTûwQÿ zÿßñÿÄÑý»¨ÿÐ=ïøÿâj‡˜?Ïÿª0ŸÿU_þÝÔè¿÷üñ4nê?ô_ûþ?øš¡æóÿê£ÌçÿÕ@ÿ·uú¯ýÿüMÛºý×þÿþ&¨yƒüÿú¨óùÿõPÿíÝGþëÿÇÿGöî£ÿ@õÿ¿ãÿ‰ª`ÿ?þª<ÁþýTûwQÿ zÿßñÿÄÑý»¨ÿÐ=ïøÿâj‡˜?Ïÿª0ŸÿU_þÝÔè¿÷üñ4nê?ô_ûþ?øš¡æóÿê£ÌçÿÕ@ÿ·uú¯ýÿüMÛºý×þÿþ&¨yƒüÿú¨óùÿõPÿíÝGþëÿÇÿGöî£ÿ@õÿ¿ãÿ‰ª`ÿ?þª<ÁþýTûwQÿ zÿßñÿÄÑý»¨ÿÐ=ïøÿâj‡˜?Ïÿª0ŸÿU_þÝÔè¿÷üñ4nê?ô_ûþ?øš¡æóÿê£ÌçÿÕ@ÿ·uú¯ýÿüMÛºý×þÿþ&¨yƒüÿú¨óùÿõPÿíÝGþëÿÇÿGöî£ÿ@õÿ¿ãÿ‰ª`ÿ?þª<ÁþýTûwQÿ zÿßñÿÄÑý»¨ÿÐ=ïøÿâj‡˜?Ïÿª0ŸÿU_þÝÔè¿÷üñ4nê?ô_ûþ?øš¡æóÿê£ÌçÿÕ@ÿ·uú¯ýÿüMÛºý×þÿþ&¨yƒüÿú¨óùÿõPÿíÝGþëÿÇÿGöî£ÿ@õÿ¿ãÿ‰ª`ÿ?þª<ÁþýTûwQÿ zÿßñÿÄÑý»¨ÿÐ=ïøÿâj‡˜?Ïÿª0ŸÿU_þÝÔè¿÷üñ4nê?ô_ûþ?øš¡æóÿê£ÌçÿÕ@ÿ·uú¯ýÿüMÛºý×þÿþ&¨yƒüÿú¨óùÿõPÿíÝGþëÿÇÿGöî£ÿ@õÿ¿ãÿ‰ª`ÿ?þª<ÁþýTûwQÿ zÿßñÿÄÑý»¨ÿÐ=ïøÿâj‡˜?Ïÿª0ŸÿU_þÝÔè¿÷üñ4nê?ô_ûþ?øš¡æóÿê£ÌçÿÕ@ÿ·uú¯ýÿüMÛºý×þÿþ&¨yƒüÿú¨óùÿõPÿíÝGþëÿÇÿGöî£ÿ@õÿ¿ãÿ‰ª`ÿ?þª<ÁþýTûwQÿ zÿßñÿÄÑý»¨ÿÐ=ïøÿâj‡˜?Ïÿª0ŸÿU_þÝÔè¿÷üñ4nê?ô_ûþ?øš¡æóÿê£ÌçÿÕ@ÿ·uú¯ýÿüMÛº—m=3Û3þ$Õ0ŸÿU8IÈë×üö 0ð³çA°ïû„}…uðÉÓëüÿÏ¥p~ø‘Xgþ}Ó®ðŠì ~ŸÓžžôÐBǯÓóþµ£tê>¼óýkúg§ùö­X[ê?NŸkFsïï×½^CŸn~Ï¥fD}àUøÛ¦?NzŸz¼„õú~&¬¯§#¿çš¦§ééùÕ¤?‡éÓó  ùçZ~3þqþé鎹À©r=½=zPÿÖýëcÇQÎj»÷ÇËüúTìqøþ¿ŸëUŸëëÿëí@¤$ŸÒ¨Ëß¿ù÷úUÉ{qžÝ³YÒœŸNþØ  S7\‘Þ²¦<8þ\ÿž+BfêÉüÿZɘõç×òõ  Ùß®8ÿ=+áº÷ëÇÖ´§n¿ÓÞ°î¯Ðõè1@ áw#]Õùÿ–ŸúõÝy•ç>qý»«ÿ× Oç=wjúP5ÿ §üV¿ð†ÿg\Ç¿Ÿö¼|7gÿWü;÷¬ùv÷®Ï̪>júQ毥^ó+žÖ|eáOÊxƒZÓôÉd‘/.b€°õÙIâ¼'ö²øµªüýümñ3@U:¦•ev,à2¥ÅåÄVpÈTðÂ7˜>n¿‘ø^ñf³wâ?ê7:®©!šêòòF–i\õg‘Éb~§Àâ€?´¿øZŸ ÿènпð>ßÿŽVöâ¿ øŒH|?«Yjb,y†Êxç ž›¼¶lgÞ¿‡šì<ã¿|4ñ=Œü«]hšÎ ’ «W(ì::60ÈÀ«q@Û—™G™_–ß³¿ü÷à¯ü%kmñ“R‹Á>-¶ŒGx&ŠccrÀ`Ío*+„Vêc©RHRàn¯£¿á¹ÿd¯ú)Ú'ý÷'ÿ@]ù”y•òÎûg~Ë:ö£•§|Mðñ¹¸`‘ ® Xð|Á$ð#'Í},³¤Š2¬¬R§ ƒÐƒÜÐó(ó*š¾”y«é@¼Ê<ʣ毥júPÄ¿µ¯íéðïöV»³ðÍÆ™?Š|W}ºM¬Én@IU’æá–O,¹S±V7$HUÁo†?áôçþˆïþ\?ý쯅ÿà¥I7í¡ñ]Ù–5ÒU9 ‘f؃,OÔšøR€?tÿáôçþˆïþ\?ýì£þNèŽÿåÃÿÞÊü,¢€?tÿáôçþˆïþ\?ýì£þNèŽÿåÃÿÞÊü,¢€?tÿáôçþˆïþ\?ý쮃Ã?ðYÏ ÞëVÖÞ.ø_w¤ér8Yï,µe½–%'–Ýí-ƒã©bŸJü ¢€?¸/ x»Ãþ8ðÖ—ã ÞǨiͬw¶WQr²E*‡Fç@`r]™_ÿÁ8.$—ö.øtÓ1vUÕPrpº½â¨úa_py«é@¼Ê<ʣ毥júPï22¨ù«éGš¾”{̣̪>júQ毥^ó(ó*š¾”y«é@¼Ê<ʣ毥júPï22¨ù«éGš¾”{̣̪>júQ毥^ó(ó*š¾”y«é@¼Ê<ʣ毥júPï22¨ù«éGš¾”{̣̪>júQ毥^ó(ó*š¾”y«é@¼Ê<ʣ毥júPï22¨ù«éGš¾”{̣̪>júQ毥^ó(ó*š¾”y«é@¼Ê<ʣ毥júPï22¨ù«éGš¾”{̣̪>júQ毥^ó(ó*š¾”y«é@¼Ê<ʣ毥júPï22¨ù«éGš¾”{̣̪>júQ毥^ó)VL3Þ¨y«éJ² ÷4æÞø‘XvÿGOýq]¤ Ÿëù×áf·aùà;dWip3Óè¡…ºr+Zþ{sþx¬([õýÏ­lBxüÿ—Z؈ô­Î}ë.œz{VŒ}=¨ú“È«J1ÇNŸNÏMÏ×õÏnZ´¿çòë@W§ž•09¨W¿ôç*ôGåýhÿ×ýêlŒÿž­TsŽ9_óÍZ Î1UŽØëíÓó  ’t9úV|§¯nŸùô«ò{þ¼Vt‡Ž:ûsÒ€3¦'9ÿëÿZÇœúñéŸóÍjÌy=>ùþ•1äŒc‡JɸnR}ëå?^üŸÊµî8öõÏ…pAÎ:óÓž”OîW\Õ½à´çß3×gæÿŸòk‚Ðæ«ÓýE§óšºÿ2€9ÏøE¿â´ÿ„¿ûBãþ=ü²gäé·Ïú¿âÙõŸ6îÕØù¿çüšàÿá0?ð˜ÿÂ%ö õÚ±òtÝœcîüÿ¬ùqÞºï2€>,ÿ‚>bÿˆc=²?ôóg_ÊåS?ðQÇÏìgñØ'ÿOuüÑ|4øyâOŠþ=ÐþxJ?U×nÒÖsµ7ròÈ@$GîppªMptWî_õ/Øgöm7áN¯ðîßâwŒ¤w½Þ¡mkvèÒ(*ÒÍñÂÒ¹!‰8k1Ë+7ÿÃv~Åômþ 4oþ1@Wì?ü7gìQÿFÑ¡ÿà³FÿãÃv~Åômþ 4oþ1@ý'ÿÁ*þ%ø—Æß³Õÿ‡üIw-èð޲únŸ,¬YÖÍàŠhá,NH‰™Âäð…TaT øßþ³ö(ÿ£hÐÿðY£ñŠôÿÁQ?g߇Öséþø5'†­.dó§ƒG[ 8ä(]î"+6 œ t Ûß7üÿ“G›þɯϟٷþ ð¿öŒñÏü+›=#Qðæ¹q“ØGzÑËÐ… ’¢Wˆµ9â(ð:žÒ$ Ý6ÚF#3º­ý øãö:øã?‚p| M 'GÓb?Ù7vˆ¿k³¹ÛÿK+|Ï#eÞO›’±I[[YYy¿b‚(<ùZy|¤Tß#ãt´ ÌØ'$÷5ù“ûv~Þv¬îþ|(ºŠïÇ—1¼½]¯޽HåZé”åäFguXðcã'à _à¿Äÿ|.×n­¯¯|?wög¹´;¢•YVHÝ{©de,§”l©äó ½}}{ª^ÜjzÄ·wwR¼óÏ;—’I–wwbY™˜’I$’rjTðM÷Çì_ððg§ö¿þžo+î7üÿ“_ Á8ß±ŸÃÑÿaoý<^WÛ¾e^óÏù4y¿çüš£æQæPï7üÿ“G›þɪ>ee^óÏù4y¿çüš£æQæPï7üÿ“G›þɪ>ee^óÏù4y¿çüš£æQæPï7üÿ“G›þɪ>ee^óÏù4y¿çüš£æQæPï7üÿ“G›þɪ>ee^óÏù4y¿çüš£æQæPï7üÿ“G›þɪ>ee^óÏù4y¿çüš£æQæPï7üÿ“G›þɪ>ee^óÏù4y¿çüš£æQæPï7üÿ“G›þɪ>ee^óÏù4y¿çüš£æQæPï7üÿ“G›þɪ>ee^óÏù4y¿çüš£æQæPï7üÿ“G›þɪ>ee^óÏù4y¿çüš£æQæPï7üÿ“G›þɪ>ee^óÏù4y¿çüš£æQæPï7üÿ“G›þɪ>ee^óÏù4y¿çüš£æQæPï7üÿ“NY>aÏqYþe9dùÇ=Åp~lèvg÷3ø ímÛ§¯åŒóÚ¸? 4Kñû„Îxí]µ±é·ôç¥tVäÿ/ÄÖÄÛëXPúZÚ€ôíÛÓ¥lDzu5£nÜÿŸóŠË‡gñϧéý9é@ä~Ÿ˜þui8ÇQõÿ<Õ4?OLJ¸‡¦;ztüèÊóŽù©—¥B½FñNÈËò ÿÐýçsÇQÞª¿p8þ\ÿž*Óž¾çóüÿZªÿãÿëí@¥ã§×ÿ­YÓ½úçüšÑ“§çk6n‡>Ÿ—J̘ñÔt59ëÛ?—5­1ëî<ýZƸ=qïÿëí@×áXW<ç¿_óúVÝÁÎç¬ žùôþT¢¶ÝoTç¬6¾Ýæ®§Ì?çÿÕ\n”ÛuÍS¶aµþs}+£óÏù4Ì?çÿÕG˜Ïÿª¸?øF¿â¯ÿ„³í÷ê<Ÿ²çäé·ÏÜþ-˜ûÿ6{W[æÿŸòhãOø(³îý¾ ûÿéâξ ýŒ<áïÙsà‰l¿‰–ÀêZ…›Zx^Ê_–G…Ûl{ä5äÀÀ°!pJ±¯Ö_ŽŸ tÿÿ õ†:µÁ·°Õîôé/r­í5 {¹£R9V’8ŠÙˆ=«ð‡þ %û@ØüBñý§Á¿ºý“?d¿~Ó¾/ò ó4¿é’/öδW!G ö{}Ãpã äF§sñµ[úvøuðûÁÿ <§xÀztZ^¦EåÃ|’O/$ŽrÏ#¶K¹%™ŽM~.Á9l ø+M´ýžþ"ýŸI³–êY4=`â8Ì×.]­®ØàÎÇËœr°šúWöãýºì>é÷? >^Cyã›ÈÊÝ^ÄVHô˜Ü}ãÕZåÊ!ÈA†a÷U€?Níµ;ß7ìwOäJÐKå:¾É‘öçk.FAäw¯ÌßÛ£ö²øÓiuñSáU´Vž<¶‹}åšíHõtEèO ·J΀Ýeü•ý–¿kß~Ξ<ŸY¸žç\ðæ»sæøƒMšRï;¹ù®âw<] “¸œH>W=~6&¹©¤ê1ì»[W_µ]Üã?fX˜îI¸“p^ nÀ€*wÖ7º]ìún¥–·v²<Á:’9•tt`YXA‚0j•zgƉš·ÆO‰ž ø®ZÛÙ^k÷h{{Qˆ£UE{’¨ <³e$×™Ðõÿé}¿±·Ãáÿaoý<^WÛ^aÿ?þªøkþ Úøý޾Oí_ý;ÞWÚÞoùÿ&€/ù‡üÿú¨óùÿõU7üÿ“G›þÉ  þaÿ?þª<ÃþýUCÍÿ?äÑæÿŸòhÿ˜Ïÿª0ÿŸÿUPóÏù4y¿çüš¿æóÿê£Ì?çÿÕT<ßóþMoùÿ&€/ù‡üÿú¨óùÿõU7üÿ“G›þÉ  þaÿ?þª<ÃþýUCÍÿ?äÑæÿŸòhÿ˜Ïÿª0ÿŸÿUPóÏù4y¿çüš¿æóÿê£Ì?çÿÕT<ßóþMoùÿ&€/ù‡üÿú¨óùÿõU7üÿ“G›þÉ  þaÿ?þª<ÃþýUCÍÿ?äÑæÿŸòhÿ˜Ïÿª0ÿŸÿUPóÏù4y¿çüš¿æóÿê£Ì?çÿÕT<ßóþMoùÿ&€/ù‡üÿú¨óùÿõU7üÿ“G›þÉ  þaÿ?þª<ÃþýUCÍÿ?äÑæÿŸòhÿ˜Ïÿª0ÿŸÿUPóÏù4y¿çüš¿æóÿê£Ì?çÿÕT<ßóþMoùÿ&€/ù‡üÿú¨óùÿõU7üÿ“G›þÉ  þaÿ?þª<ÃþýUCÍÿ?äÑæÿŸòhÿ˜Ïÿª0ÿŸÿUPóÏù4y¿çüš¿æóÿê£Ì?çÿÕT<ßóþMoùÿ&€/ù‡üÿú¨óùÿõU7üÿ“G›þÉ  þaÿ?þª<ÃþýUCÍÿ?äÑæÿŸòhÿ˜Ïÿª0ÿŸÿUPóÏù4y¿çüš¿æóÿê§,„¸â³¼ßóþM9dù‡=ÅrÞÿ%‡oÜ'…vöç8Ç>¿C\/…NtKúà?Ý®ÞØð3éü¹  ûsÓ‘ÍmÃéÓßóÅaÀG_ò?Ö¶ è?Ïã@Ÿçð­8½:ÿžzÖ\<ãùqšÓ‹ß¥h'AÈúÕÄü³§?犦œ®?Ïõ«iíþ}ûP¥Ïçð©=úzóQ§õíÎ)äã×üýhÿÑýçlŒý_ëUŒŸÇüóVÚª?Ó‡O΀)IßgKÐûÿQþ{V„óíœñYÒôÿz ÊŸ98ÿõŸëX׈úÿžkb~ý:V5ÁÁéÓ¥aÜ÷þuƒrÝ~§¯çò­ëžùüsÅs÷=ÿ§=(™°$kZ™õ†ÛùÍ[¾g½sVÍkQéŸ&ÛëÖZÖó(þÏø«áûÿê<ï´ãäéœãûŸÃ»?åÇzêüÏz¡æQæP‰~Ô~+ñ¿ƒ?gïx›áÔÜx‚ËL&×Ȥˆ;¬s\ ;­âg˜p@)È"¿“fv‘‹¹,ÌrIä’z’kû6ó21\sü?ø,$¾ÑÜ–fk+rI=I%9&€?j+ûÿ…yðïþ…mÿmÿøŠ?á^|;ÿ¡[EÿÀþ"€?š+ûÿ…yðïþ…mÿmÿøŠ?á^|;ÿ¡[EÿÀþ"€?šþŒ?à_³|¿~?Ä/Yµ·‹|gHc Ëi§ƒº °Ê´§÷²(¬%}·€üi:\ÚøoH†h˜¿OÖ®5S~3ÛôéùÐ)??ÿ]fÍýŸùô­){çñÏ›/ùÇ=(*lŒŸóŸëX³ðQõÿ<ÖÔÝút¬IøÏoÓ§ç@w<ç¿ÿ®°n{ýÏùÅnÜwÏãž+ ç¿ôç¥[øŸíÝwþ¸Y;Šõ:òÏÈw]éþ¢ËëÖâ½N€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ QÔ}i)GQõ ¼+ÿ Kÿèéü«¹·è>¿ÌžÕÃøSÇ?óÁ3ž;Wqmž?§=o[g9ÿ9þµ·oÐuwú~µ‰oôçµmÛöíútüè^HïZ±tç¨ÿ=«*lçŠÕ‹·ôç¥hEœçüçúÕ´éŽG×üóU#íVÓòý:~thr=joóþy ÷Ïãž*Lü¿*ÿÔýç¨ïTäîË‘þqVØóõ?ŸçúÕI:Çùuí@¥ãüþ•›0ëßüûÖœŸçâ³&ÿ9íŠÊ›§QÞ±n1Èéü¹ÿ¿OÖ®5S~3ÛôéùÐ)??ÿ]fÍýŸùô­){çñÏ›/ùÇ=(*lŒŸóŸëX³ðQõÿ<ÖÔÝút¬IøÏoÓ§ç@w<ç¿ÿ®°n{ýÏùÅnÜwÏãž+ ç¿ôç¥[øŸíÝwþ¸Y;Šõ:òÏÈw]éþ¢ËëÖâ½N€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ QÔ}i)GQõ ¼+ÿ Kÿèéü«¹·è>¿ÌžÕÃøSÇ?óÁ3ž;Wqmž?§=o[g9ÿ9þµ·oÐuwú~µ‰oôçµmÛöíútüè^HïZ±tç¨ÿ=«*lçŠÕ‹·ôç¥hEœçüçúÕ´éŽG×üóU#íVÓòý:~thr=joóþy ÷Ïãž*Lü¿*ÿÖýç¨ïTäîË‘þqVØóõ?ŸçúÕI:Çùuí@¥ãüþ•›0ëßüûÖœŸçâ³&ÿ9íŠÊ›§QÞ±n1Èéü¹ÿ¿OÖ®5S~3ÛôéùÐ)??ÿ]fÍýŸùô­){çñÏ›/ùÇ=(*lŒŸóŸëX³ðQõÿ<ÖÔÝút¬IøÏoÓ§ç@w<ç¿ÿ®°n{ýÏùÅnÜwÏãž+ ç¿ôç¥[øŸíÝwþ¸Y;Šõ:òÏÈw]éþ¢ËëÖâ½N€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ QÔ}i)GQõ ¼+ÿ Kÿèéü«¹·è>¿ÌžÕÃøSÇ?óÁ3ž;Wqmž?§=o[g9ÿ9þµ·oÐuwú~µ‰oôçµmÛöíútüè^HïZ±tç¨ÿ=«*lçŠÕ‹·ôç¥hEœçüçúÕ´éŽG×üóU#íVÓòý:~thr=joóþy ÷Ïãž*Lü¿*ÿÐýç¨ïTäîË‘þqVØóõ?ŸçúÕI:Çùuí@¥ãüþ•›0ëßüûÖœŸçâ³&ÿ9íŠÊ›§QÞ±n1Èéü¹ÿ¿OÖ®5S~3ÛôéùÐ)??ÿ]fÍýŸùô­){çñÏ›/ùÇ=(*lŒŸóŸëX³ðQõÿ<ÖÔÝút¬IøÏoÓ§ç@w<ç¿ÿ®°n{ýÏùÅnÜwÏãž+ ç¿ôç¥[øŸíÝwþ¸Y;Šõ:òÏÈw]éþ¢ËëÖâ½N€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ (¢€ QÔ}i)GQõ ¼+ÿ Kÿèéü«¹·è>¿ÌžÕÃøSÇ?óÁ3ž;Wqmž?§=o[g9ÿ9þµ·oÐuwú~µ‰oôçµmÛöíútüè^HïZ±tç¨ÿ=«*lçŠÕ‹·ôç¥hEœçüçúÕ´éŽG×üóU#íVÓòý:~thr=joóþy ÷Ïãž*Lü¿*ÿÒýç¨ïTäîË‘þqVØóõ?ŸçúÕI:Çùuí@¥ãüþ•›0ëßüûÖœŸçâ³&ÿ9íŠÊ›§QÞ±n1Èéü¹ÿ¿OÖ®5S~ücôéùÐ)??ÿ]fÍý§ùíZR¤þ9â³¥öý9è(&\óþyþµ‹p0;þ?çšÚŸ¿N•‹qéŒ~?:ø¿ÿ®°.{ýÏùÅoÜñX=ÿ§=(߀?ä;®ÿ× /çq^§^[àùë½?ÔY}zÜW©PEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPEPJ:­%(ê>´ó¿…ä aßý îm¹Ç×ùóÚ¸ ø’XgÜ&sÇðŠî-{NzPí¾Aü¿?ë[–ü×§óÍbAÔdßþªÛƒŒvãéÓó  xy#©÷­HºóÔžÕ™¿¶sÅiÂ:céÇ=(B,ç?ç?Ö­§Lr>¿çš¦zOþµ\OËôéùСÈõ©¿ÏùäT+ß?Žx©2òü¨ÿÔýæ¨ïU\`àqœ}*Ûž¾çóüÿZ¨ãúÿúûP)8Î=¿ýU7~ýsþMiH2ò«2^„{wíŒPLýù cÜN8þ_çÒ¶gêO¯ëùþµp9?ÿ¯µ`Ü÷ÿ?…`\‚s߯ùý+¡¸ϧ·=kç¾}Z³àùkŸõÂÏÿB¸¯R¯/ðÿ‰î»ÿ\,¿Åz…QEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQEQE dŠJQÖ€>yð¶±lë‚!ÅvöÝ»×á_ùØ×éÏjí­Æ@Ïÿ«¹AÒ¶à`tþ_çÒ±`àž?Ö· Çùuí@ЃÇùÿ´óï…W:%‡ýpAú î-ÁúÿOóÚ¸¿ ¯üHìsÇî®a]½°NzPÕ°9ÏÓóþµµÇ·Ö±íÇn>ŸÒ¶ ãc·§O΀5biÅÛëüÿÏ¥fÂ:íœàVœ]¯·=(ôyþ_Ÿõ«iÛ9:ÿžj¢aéúUÅã¶9útüèÊsïRÿŸóȨ—gñÏüü¿*ÿÖýé~º« ÆGOåÏùâ­·óý?Öª¸þ¿Ë¯j£ ÿ?Ò³åÏóïZOÏÿ[¶k>aÁÏLwíŽh&qסÎk"àrF1ü¿Ï¥mL:ûþ¿ç½dνG×ÿ×Ú€0.çÃúVÊò{õ®ŠàuãòíšÃ¸^¿C×Ú€ ðcù~&Ô '™í!qÛ>S¸?ú¯Q¯†õtOYêò¶î ­Ë%*U‰=º©'°É¯h¿çš¶Üç¦}?¥Wn½1út  R½ë>Qþqíþ{V“ŒuüsÇJ£ ÏOÓž”•*àœúÍdÌ¿‡Ö¶æ^£úÖ\ËÉíútüèuÎ{ÿúëázý˜ôÿëWE2õÏ㜠Ǚ:ñýz{Ð/yn³FñJ7+ŒFAÏcØÒhž+»ðÜi§jé-Öž˜Xn#å‰GDuê꣡0mn¢üé×ô¬iãê1Ó§ç@câê 5–§k!oàó8ÿy†SìTÜVYèÈ`{ŽE|á{¥Ù\dÜAú–¹Ùü9£ÿPdÿ°§§á@Yí>”`ŽÕñľÑ¿çÊûá*ϓÚ?üùB?à :~€>×Ú}( ŽÕðËøsGïe¾QEToèÿóåà€ôü?Æ€>ñÚ}(Á«àfðîŽåÊO¸¹úT áÍ#þ|¡ð:~€?@6ŸJ#µ~}Ÿhüÿ¡Â=IEÒ£ÿ„wGÿŸ8sì€ôü(ô#iô£v¯ÏcáÝ#?ñåýð¿áM>Òÿ8GüŸ…~…í>”Gjüôÿ„wGláüQGô xwHígà€ôü(ô/iô£v¯Ï_øG´ùóƒé°RÂ=¤gþ<âð:~€?BöŸJ#µ~zÂ;£Ž¶pþ(£úP<;¤v³‡ð@z~ú´úQ‚;Wç¯ü#ÚGüùÁôØ)?áÒ3ÿqø?@¡{O¥Ú¿=?áÑÇ[8Qý(Ò;YÃø =? ý Ú}(Á«ó×þí#þ|àúlŸðiÿ8‡üŸ н§Ò‚í_žŸðŽèã­œ?Š(þ”é¬áüŸ…~…í>”`ŽÕùëÿö‘ÿ>p}6 OøG´ŒÿÇœCþOÀÐè^ÓéAv¯ÏOøGtqÖÎÅJ‡tŽÖpþO€?BöŸJ0Gjüõÿ„{HÿŸ8>›'ü#ÚFãÎ!ÿ§àhô/iô ‚;Wç§ü#º8ëgâŠ?¥úGk8§á@¡{O¥#µ~zÿÂ=¤ÏœM‚“þí#?ñçÿ€Óð4ú´úPA«óÓþÝu³‡ñEÒáÝ#µœ?‚Óð н§ÒŒÚ¿=áÒ?çΦÁIÿö‘ŸøóˆÀéøý Ú}( ŽÕùéÿîŽ:ÙÃø¢é@ðî‘ÚÎÁéøPè^ÓéFí_ž¿ðióçÓ`¤ÿ„{HÏüyÄ?àtü ~…í>”Gjüôÿ„wGláüQGô xwHígà€ôü(ô/iô£v¯Ï_øG´ùóƒé°RÂ=¤gþ<âð:~€?BöŸJ#µ~zÂ;£Ž¶pþ(£úP<;¤v³‡ð@z~ú´úQ‚;Wç¯ü#ÚGüùÁôØ)?áÒ3ÿqø?@¡{O¥Ú¿=?áÑÇ[8Qý(Ò;YÃø =? ý Ú}(Á«ó×þí#þ|àúlŸðiÿ8‡üŸ н§Ò‚í_žŸðŽèã­œ?Š(þ”é¬áüŸ…~…í>”`ŽÕùëÿö‘ÓìpgýÁKÿî?åÊû䟠Ч҂í_žÿðŽéæÊë”Qý)ÃúGüùÃø =? ýÚ}(Á«óôxwHÿŸ(>›5*øsGòåÿ€Óð ¿vŸJ#µ|žÑûÙAø¢éVcðîÿ>pþO€>ìÚ}(Á«âü9£Ÿùrƒþø_ʯÅáÍþ…ÿ€(éøûCiôªwö6ŠZêæ(êeuAÿ_&ÃáÍ<Ù@:uEÓ­lÚè:Ldyv‚=Ç¿ÿ®€=¯Sø¡Z‡Kí[ž‹© ™ÿnnP\Ge5ÃEöíBùõ}]Ä—R(UUû‘ ä"“Žäõ'“U­mÒ T0‡«réÁ§O΀4 LõÉý{ÖÔ Óëߌd~¿•gÀƒ¿^+fcôç¥hÀ§ù~'úÖ´+Ó·××úÖ|+žÃüÿJÖ„cút  у¯oZÐŒtíÏ~:ÿŸJ§ã¯^*üC§ôç¥[@ϯõ«(éÛŸZG°ôÅYQoÓ§§Zœsžùã?H ãüj5àsúñR‚_§å@ÿÐýíor0A櫸íÓ?•ZnŸçŸÏõ¨z{Ÿþ½Sqéþ}ª”€œ÷ÿ>ÿJÑqžƒnÙªr.G>Ÿ–(.U÷ë6eê:.kbA××ùÿžõŸ2uüýt…2ÖLñõïþt§·åÛ5—2uãµss§»ÖDñrp1ü¿Ï¥tÓGíÏóüÿZÊš.¿òë@¼Ñx¬©¡ÉÝù¨ZÜsÆéÍt-oê?úÿŸëQ~¿ÿ®€9æ·öǵ4Û“ž3üëìù=?.Ù¦~Øíÿ×  Lã¾)¦Ü1þÿž+{ìÞÝ_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨Èõ—ìýº.ϼmÆz_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨Èõ—ìýº.ϼmÆz_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨Èõ—ìýº.ϼmÆz_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨Èõ—ìýº.ϼmÆz_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨Èõ—ìýº.ϼmÆz_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨Èõ—ìýº.ϼmÆz_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨Èõ—ìýº.ϼmÆz_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨Èõ—ìýº.ϼmÆz_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨Èõ—ìýº.ϼmÆz_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨Èõ—ìýº.ϼmÆz_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨Èõ—ìýº.ϼmÆz_Ïõ£ìß×ÿ×@>GµA=³þ}ë{ìùíùQö|õ¨È=À§}œzcùsþx­ß³Û¯ëùþ´}›Óßÿ×@Ÿgç§áNüãëþy­¡oÏN=½8[ätíü¨[ôÎ;óÒ¦ý€Çòÿ>k\[ö=ÿ_nGçR-¿çÏÿ®€2ÖéøJ²°vÆϿҴ–Üç§à;U…·àdvïí@R\Uø ÁÆ1ü¹«IN¿¯æ?:»×ÿ×@ŵjÃ=3þ}éÑAœqùVŒPŒ ò(ÐÅÇj؆!Çéôçòü*böÿëþ¯Öµa‹Óßÿ×@!§çÒµá·_óïU¡=¸ô³ZÇÐ:µ ú‘Z±/áœ}9ÿ¾çùÿŸJÝ‘:ç®P’?oËž”,DÇÿ¯úÖ\±}Gך褋$ð3T%‹ž˜ý:~tÍKsÆ^õ,=ñŽ}1×üúWM$=r?< ¡$°ü¹é@Ì–çž?ýÖ©5¿±_ZéÞ ç U7·ÁéŽÞ=(™{|öÏø ¬ÖÞÝOÓüþUÒµ·áõâ 6ýp:ñÇ==ÿúÔÍ›cÏBÖüú×JÖÞÃéQ5·8Æ?NŸs¦Û¯íúÔföêÏùÅtfØr1îx¦ ooËž”€-ùõ¦µ¶L}k¡û7µfÿgútüè›û.{gµfþçüâº?²ãç²Ãòç¥s¢ØÿŸZO³`rúú×GöQéIöAžãôé@ïÙsÛ=¨û7óÿ?çÑ}£óÀ¥û ì?.zP:-ùõ¤û6 ¯­te”Ÿdî?N”Îý—=³Ú³?óþq]Ùê?< _²Ãòç¥s¢ØÿŸZO³`rúú×GöQéIöAžãôé@ïÙsÛ=¨û7óÿ?çÑ}£óÀ¥û ì?.zP:-ùõ¤û6 ¯­te”Ÿdî?N”Îý—=³Ú³?óþq]Ùê?< _²Ãòç¥s¢ØÿŸZO³`rúú×GöQéIöAžãôé@ïÙsÛ=¨û7óÿ?çÑ}£óÀ¥û ì?.zP:-ùõ¤û6 ¯­te”Ÿdî?N”Îý—=³Ú³?óþq]Ùê?< _²Ãòç¥s¢ØÿŸZO³`rúú×GöQéIöAžãôé@ïÙsÛ=¨û7óÿ?çÑ}£óÀ¥û ì?.zP:-ùõ¤û6 ¯­te”Ÿdî?N”Îý—=³Ú³?óþq]Ùê?< _²Ãòç¥s¢ØÿŸZO³`rúú×GöQéIöAžãôé@ïÙsÛ=¨û7óÿ?çÑ}£óÀ¥û ì?.zP:-ùõ¤û6 ¯­te”Ÿdî?N”Îý—=³Ú³?óþq]Ùê?< _²Ãòç¥s¢ØÿŸZO³`rúú×GöQéIöAžãôé@ïÙsÛ=¨û7óÿ?çÑ}£óÀ£ì£°ü¹é@ðµ9ééùÓþÍêú×@-Fz Ñöol~?:çþËžÙíN[|Ïùâ·¾ÊQùàR­·<Ëž”†-ˆ=?úæ¤ø#nkoìÞÕ'Ù»àÓ§ç@ËmíŸÿ]N¶þÝý1þ*ר<©½Æ?ÏåVR uõÏz(qËž”1ú~Ö´¢‹¦A_óÍ:(½‡Ò´"‹ã§O΀(údg·ëZQGÓëߎ£üö¨â‹ñZ1GÓúsÓüûб'?ç“ýjükÀê>¿çšŠ4ö×ôíWQqÔút  Pg¨'·ëVÐ?§ùüª$_Ã×8iíߎ9é@';ÔËøŽÜÿži€{LT ccôé@ǯÿ®ž4qÿ×⟀ ÿÒýô=;sQ‘œŒcùŸJ”ñøþ¾ßãL#¸>¿Ë¯j®ÞßçÚ uÎG_ê?¶ÃžœzÙ¨X.þÔE×éÞªºuíü¿Ï¥h²þ¿çþµYׯãÿë  ·Olž•NHúñŸZ×tÏOÓ¶j£§lqŽþÔ‰$_CÖ©I¶3ùs[¯^:þ¿ŸëU.¿ÿ®€0$‡Ûš§$Ïÿ>õм9íøÙªò(ž’¥Uk~ØÆ.ÏÑ4>Ý_nZ®Ðuüýtε¿|T-mž1Ÿ_òk¢h=¿Û5 ·ãÝèžk\Tf×“ÇøŸJèšA×õÿ=é†ß¯ÿ®€9æ¶ÇAþýTϳ{güû×DmòzqíLû7·oå@fã·~Ô}˜vãùsþx­ÿ³Î:þ¿ç½gôç¯ÿ¯šÀû7µföÏù÷­ï³ç ü¹¤ò éü¨ ìÜvïÚ³Ü.ϼ`>ƒŸ×óýi>Î}=ýt…öoj>ÍíŸóï[ÞF{~TžAô?•a}›ŽÝûQöaÛåÏùâ·ŒÐsúþ­'Ùϧ¯ÿ®€0¾ÍíGÙ½³þ}ë{ÈÏoÊ“È>ƒ§ò  /³qÛ¿j>Ì;qü¹ÿž¿þºÂû7µföÏù÷­ï#=¿*O úŸÊ€0¾ÍÇný¨û0íÇòçüñ[Æè9ý?Ö“ìçÓ×ÿ×@_fö£ìÞÙÿ>õ½äg·åIäAÓùPÙ¸íßµf¸þ\ÿž+xÀ}?¯çúÒ}œúzÿúè ìÞÔ}›Û?çÞ·¼Œöü©<ƒè:*Âû7»ö£ì÷ËŸóÅo çõüÿZO³ŸO_ÿ]a}›Ú³{güûÖ÷‘žß•'}Oå@_fã·~Ô}˜vãùsþx­ãôþ¿ŸëIöséëÿë  /³{QöolÿŸzÞò3Ûò¤ò éü¨ ìÜvïÚ³Ü.ϼ`>ƒŸ×óýi>Î}=ýt…öoj>ÍíŸóï[ÞF{~TžAô?•a}›ŽÝûQöaÛåÏùâ·ŒÐsúþ­'Ùϧ¯ÿ®€0¾ÍíGÙ½³þ}ë{ÈÏoÊ“È>ƒ§ò  /³qÛ¿j>Ì;qü¹ÿùþTÕ\ý=ª`8翯lP”t銕G'.Ïß§×óýjL_ÿ](üÿ*~3ߥ éÇéA8ïŠÿÓýøçŸ_ëQŸÄ}Ï5&9ã:SHöÇéÓó ˆÏlƒïžõ¾½~¿çÒ§ cŸÌñQ?nzPr§'üäÔ ¿Qõÿ<ÕÆ\žƒ?çô¨ŠûcÛ§OJ¢Ë× ú~µYÓ>Üý?ÏåZ  uüIâ¡)é×ÛžžôšÑœž?ýÖª¼__ÇüóZÌžÃ>•]£Çl~?:Éx½³ÿëªïáÏÓüþU°bç‘\ñP˜Ç§åÏOóï@Í çÿ_½@Ðã±ZÚh³ØgüñQ˜½±útüè ÏõAíþ¸ÿ=«hÃŽ¼}x¦y#°ü¹éï@¾A±ýM3Èö?­m3Øgõý)<œ„}8é@ŸgÏoǯzCoüûñúõ«kÈÇÔœ A Ï¥c}Ÿÿ­ßúÒcøÖדì>Ÿþª<œvÇéÓó  _#ØŸÿ]G·Luÿ>•³äzñ둊?äsÒ€1„=åÔÿZ<€;õ­Ÿ'>ŸJ<ŒvÇéÒ€1¼býtyÝý1×üúVÏ‘ëÇ®F(ÿ‘ÏJÆ`ô?—SýhòìGÖ¶|œú}(ò1Û§JÆò=‰ÿõÑä{wôÇ_óé[>G¯¹ CþG=(AƒÐþ]Oõ£È±ZÙòséô£ÈÇl~(Èö'ÿ×G‘íßÓÏ¥lù¼zäbùô  aCùu?Ö Ä}kgÉϧÒ#±út  o#ØŸÿ]G·Luÿ>•³äzñ둊?äsÒ€1„=åÔÿZ<€;õ­Ÿ'>ŸJ<ŒvÇéÒ€1¼býtyÝý1×üúVÏ‘ëÇ®F(ÿ‘ÏJÆ`ô?—SýhòìGÖ¶|œú}(ò1Û§JÆò=‰ÿõÑä{wôÇ_óé[>G¯¹ CþG=(AƒÐþ]Oõ£È±ZÙòséô£ÈÇl~(Èö'ÿ×G‘íßÓÏ¥lù¼zäbùô  aCùu?Ö Ä}kgÉϧÒ#±út  o#ØŸÿ]G·Luÿ>•³äzñ둊?äsÒ€1„=åÔÿZ<€;õ­Ÿ'>ŸJ<ŒvÇéÒ€1¼býtyÝý1×üúVÏ‘ëÇ®F(ÿ‘ÏJÆ`ô?—SýhòìGÖ¶|œú}(ò1Û§JÆò=‰ÿõÑä{wôÇ_óé[>G¯¹ CþG=(AƒÐþ]Oõ£È±ZÙòséô£ÈÇl~(Èö'ÿ×G‘íßÓÏ¥lù¼zäbùô  aoƒÐþ]ÿ­gǨúÖÏ‘ôúRù8íÓ§ç@ßg¢Üþ½ÿÏô­#@¹âì?.zPH·#·ÿ\Òˆ8ôúúÖ¿“íÏLRˆ1Û§O΀2„ì}3øÔ‚l~¿çÒ´Ä#ÓëœT‹éËž”š°ŸOþ¹©Vb>¼ÿúëDCôþµ ‹±útüÅQX}½³S¬_‡?OóùUÅ‹Ôc×£§òæ§nƒñ¦·€*˜ý¿Ï¥FÑóÓ?áøÕ¦ëÿ¦¿…S1ñÛœóQ˜½±ü¹ü¿ ²ßp~4?Ýÿ¾h™‹ÐŸJi‡¶3þZÿâ©_€*½qÞ“Éíðæ¦nŸIü?•S1z {qA‹Ûüþ5;}êsPcÓ¿¥'’:cËŸóÅLz~u!éÿ|Ð_'ÐbƒÔÕŠrõ  †/ëG•oåÏùâ§?ãNnŸ÷ÍWòqÓüûRy^ÄÕŸþ*œ½h¡‹úÑåcÛùsþx©ÏøÓ›§ýó@üœtÿ>ÔžW±5gÿЧ/Z¨bþ´yXöþ\ÿž*sþ4æéÿ|Ð'?ϵ'•ìMYÿâ©ËÖ€*¿­V=¿—?犜ÿ9ºß4_ÉÇOóíIå{Vøªrõ  †/ëG•oåÏùâ§?ãNnŸ÷ÍWòqÓüûRy^ÄÕŸþ*œ½h¡‹úÑåcÛùsþx©ÏøÓ›§ýó@üœtÿ>ÔžW±5gÿЧ/Z¨bþ´yXöþ\ÿž*sþ4æéÿ|Ð'?ϵ'•ìMYÿâ©ËÖ€*¿­V=¿—?犜ÿ9ºß4_ÉÇOóíIå{Vøªrõ  †/ëG•oåÏùâ§?ãNnŸ÷ÍWòqÓüûRy^ÄÕŸþ*œ½h¡‹úÑåcÛùsþx©ÏøÓ›§ýó@üœtÿ>ÔžW±5gÿЧ/Z¨bþ´yXöþ\ÿž*sþ4æéÿ|Ð'?ϵN{güûÕþ*”u  ÞW®;Òù8éÿÖæ¥?ãRöü¨¯“ŽØü¨òG×üûÔïÞŠƒÉã ïÎ)ÞV;cùsþx«ÁùÒ·Oûæ€ ž)Â/Çü?”t©¯á@ˆ°Niâ.¸Î>ŸÓð§á©»ß4‹çô©D~Ùþ£ñ§ýšž¿Ãô „÷ù©–<{ÓŸóÅEü5dýÏûæ€Wßç¥H>þÝÍ'ÿR§úÁ@ ·œw©úñü¹ÿ str: T = "::: app_model.types" T2 = T + "\n\toptions:\n\t\tdocstring_section_style: table" return md.replace(T, T2) app-model-0.3.0/mkdocs.yml000066400000000000000000000036461467241150700154130ustar00rootroot00000000000000site_name: App Model site_url: https://github.com/pyapp-kit/app-model site_author: Talley Lambert site_description: Generic application schema implemented in python. # strict: true repo_name: pyapp-kit/app-model repo_url: https://github.com/pyapp-kit/app-model copyright: Copyright © 2021 - 2023 Talley Lambert watch: - src nav: - index.md - getting_started.md # defer to gen-files + literate-nav - API reference: reference/ plugins: - search - gen-files: scripts: - docs/gen_ref_nav.py - literate-nav: nav_file: SUMMARY.txt - autorefs - mkdocstrings: handlers: python: import: - https://docs.python.org/3/objects.inv - https://ino.readthedocs.io/en/latest/objects.inv options: extensions: - griffe_fieldz docstring_style: numpy docstring_options: ignore_init_summary: true docstring_section_style: list filters: ["!^_"] heading_level: 1 inherited_members: true merge_init_into_class: true separate_signature: true show_root_heading: true show_root_full_path: false show_signature_annotations: true show_bases: true show_source: true markdown_extensions: - tables - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences - pymdownx.details - admonition - toc: permalink: "#" - pymdownx.emoji: emoji_index: !!python/name:material.extensions.emoji.twemoji emoji_generator: !!python/name:material.extensions.emoji.to_svg theme: name: material icon: repo: material/github logo: material/application-cog-outline features: - navigation.instant - navigation.indexes - search.highlight - search.suggest - navigation.expand extra_css: - css/style.css hooks: - docs/my_hooks.py app-model-0.3.0/pyproject.toml000066400000000000000000000110371467241150700163150ustar00rootroot00000000000000# https://peps.python.org/pep-0517/ [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" # https://peps.python.org/pep-0621/ [project] name = "app-model" description = "Generic application schema implemented in python" readme = "README.md" requires-python = ">=3.8" license = { text = "BSD 3-Clause License" } authors = [{ email = "talley.lambert@gmail.com" }, { name = "Talley Lambert" }] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Desktop Environment", "Topic :: Software Development", "Topic :: Software Development :: User Interfaces", ] dynamic = ["version"] dependencies = [ "psygnal>=0.3.4", "pydantic>=1.8", "pydantic-compat>=0.1.1", "in-n-out>=0.1.5", "typing_extensions", ] # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] qt = ["qtpy", "superqt[iconify]"] test = ["pytest>=6.0", "pytest-cov"] test-qt = [ "app-model[qt]", "app-model[test]", "pytest-qt", "fonticon-fontawesome6", ] dev = ["app-model[test-qt]", "ipython", "mypy", "pdbpp", "pre-commit", "rich"] docs = [ "griffe-fieldz", "griffe==0.36.9", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-macros-plugin==1.0.5", "mkdocs-material==9.4.1", "mkdocs==1.5.3", "mkdocstrings-python==1.7.3", "mkdocstrings==0.23.0", "typing_extensions>=4.0", ] [project.urls] homepage = "https://github.com/pyapp-kit/app-model" repository = "https://github.com/pyapp-kit/app-model" [tool.hatch.version] source = "vcs" [tool.hatch.envs.test] features = ["test"] [tool.hatch.envs.test.scripts] run = "pytest -v --color=yes --cov-config=pyproject.toml -W i --cov=app_model --cov-report=xml --cov-report=term-missing" # https://pycqa.github.io/isort/docs/configuration/options.html [tool.isort] profile = "black" src_paths = ["src/app_model", "tests"] # https://github.com/charliermarsh/ruff [tool.ruff] line-length = 88 src = ["src", "tests"] target-version = "py38" [tool.ruff.lint] pydocstyle = { convention = "numpy" } select = [ "E", # style errors "W", # style warnings "F", # flakes "D", # pydocstyle "D417", # Missing argument descriptions in Docstrings "I", # isort "UP", # pyupgrade "C4", # flake8-comprehensions "B", # flake8-bugbear "A001", # flake8-builtins "RUF", # ruff-specific rules "TID", # tidy imports "TCH", # type checking ] ignore = [ "D401", # First line should be in imperative mood ] [tool.ruff.lint.pyupgrade] # Preserve types, even if a file imports `from __future__ import annotations`. keep-runtime-typing = true [tool.ruff.lint.per-file-ignores] "tests/*.py" = ["D", "E501"] "demo/*" = ["D"] "docs/*" = ["D"] "src/app_model/_registries.py" = ["D10"] "src/app_model/context/_expressions.py" = ["D10"] "src/app_model/types/_keys/*" = ["E501"] # https://docs.astral.sh/ruff/formatter/ [tool.ruff.format] docstring-code-format = true # https://docs.pytest.org/en/6.2.x/customize.html [tool.pytest.ini_options] minversion = "6.0" filterwarnings = ["error", "ignore:Enum value:DeprecationWarning:superqt"] # https://mypy.readthedocs.io/en/stable/config_file.html [tool.mypy] files = "src/**/*.py" strict = true disallow_any_generics = false disallow_subclassing_any = false show_error_codes = true pretty = true plugins = ["pydantic.mypy"] [[tool.mypy.overrides]] module = ["tests.*", "docs.*"] disallow_untyped_defs = false [[tool.mypy.overrides]] module = ["qtpy.*"] implicit_reexport = true [tool.coverage.run] source = ["app_model"] # https://coverage.readthedocs.io/en/6.4/config.html [tool.coverage.report] exclude_lines = [ "pragma: no cover", "if TYPE_CHECKING:", "@overload", "except ImportError", "return NotImplemented", "pass", ] skip_covered = true show_missing = true # https://github.com/mgedmin/check-manifest#configuration [tool.check-manifest] ignore = [ ".github_changelog_generator", ".pre-commit-config.yaml", "tests/**/*", "codecov.yml", "demo/**/*", "docs/**/*", ".readthedocs.yaml", "mkdocs.yml", "CHANGELOG.md", ".ruff_cache/**/*", ] [tool.typos] default.extend-ignore-identifiers-re = ["to_string_ser_schema"] app-model-0.3.0/src/000077500000000000000000000000001467241150700141665ustar00rootroot00000000000000app-model-0.3.0/src/app_model/000077500000000000000000000000001467241150700161265ustar00rootroot00000000000000app-model-0.3.0/src/app_model/__init__.py000066400000000000000000000006461467241150700202450ustar00rootroot00000000000000"""Generic application schema implemented in python.""" from importlib.metadata import PackageNotFoundError, version try: __version__ = version("app-model") except PackageNotFoundError: # pragma: no cover __version__ = "uninstalled" from ._app import Application from .registries._register import register_action from .types import Action __all__ = ["__version__", "Application", "Action", "register_action"] app-model-0.3.0/src/app_model/_app.py000066400000000000000000000234121467241150700174210ustar00rootroot00000000000000from __future__ import annotations import contextlib import os import sys from typing import ( TYPE_CHECKING, ClassVar, Dict, Iterable, List, Literal, MutableMapping, Optional, Tuple, Type, overload, ) import in_n_out as ino from psygnal import Signal from .expressions import Context, app_model_context from .registries import ( CommandsRegistry, KeyBindingsRegistry, MenusRegistry, register_action, ) from .types import ( Action, ) if TYPE_CHECKING: from .expressions import Expr from .registries._register import CommandCallable, CommandDecorator from .types import ( DisposeCallable, IconOrDict, KeyBindingRuleOrDict, MenuRuleOrDict, ) class Application: """Full application model. This is the top level object that comprises all of the registries, and other app-namespace specific objects. Parameters ---------- name : str A name for this application. raise_synchronous_exceptions : bool Whether to raise exceptions that occur while executing commands synchronously, by default False. This is settable after instantiation, and can also be controlled per execution by calling `result.result()` on the future object returned from the `execute_command` method. commands_reg_class : Type[CommandsRegistry] (Optionally) override the class to use when creating the CommandsRegistry menus_reg_class : Type[MenusRegistry] (Optionally) override the class to use when creating the MenusRegistry keybindings_reg_class : Type[KeyBindingsRegistry] (Optionally) override the class to use when creating the KeyBindingsRegistry injection_store_class : Type[ino.Store] (Optionally) override the class to use when creating the injection Store context : Context | MutableMapping | None (Optionally) provide a context to use for this application. If a `MutableMapping` is provided, it will be used to create a `Context` instance. If `None` (the default), a new `Context` instance will be created. Attributes ---------- commands : CommandsRegistry The Commands Registry for this application. menus : MenusRegistry The Menus Registry for this application. keybindings : KeyBindingsRegistry The KeyBindings Registry for this application. injection_store : in_n_out.Store The Injection Store for this application. context : Context The Context for this application. """ destroyed = Signal(str) _instances: ClassVar[Dict[str, Application]] = {} def __init__( self, name: str, *, raise_synchronous_exceptions: bool = False, commands_reg_class: Type[CommandsRegistry] = CommandsRegistry, menus_reg_class: Type[MenusRegistry] = MenusRegistry, keybindings_reg_class: Type[KeyBindingsRegistry] = KeyBindingsRegistry, injection_store_class: Type[ino.Store] = ino.Store, context: Context | MutableMapping | None = None, ) -> None: self._name = name if name in Application._instances: raise ValueError( f"Application {name!r} already exists. Retrieve it with " f"`Application.get_or_create({name!r})`." ) Application._instances[name] = self if context is None: context = Context() elif isinstance(context, MutableMapping): context = Context(context) if not isinstance(context, Context): raise TypeError( f"context must be a Context or MutableMapping, got {type(context)}" ) self._context = context self._context.update(app_model_context()) self._context["is_linux"] = sys.platform.startswith("linux") self._context["is_mac"] = sys.platform == "darwin" self._context["is_windows"] = os.name == "nt" self._injection_store = injection_store_class.create(name) self._commands = commands_reg_class( self.injection_store, raise_synchronous_exceptions=raise_synchronous_exceptions, ) self._menus = menus_reg_class() self._keybindings = keybindings_reg_class() self.injection_store.on_unannotated_required_args = "ignore" self._disposers: List[Tuple[str, DisposeCallable]] = [] @property def raise_synchronous_exceptions(self) -> bool: """Whether to raise synchronous exceptions.""" return self._commands._raise_synchronous_exceptions @raise_synchronous_exceptions.setter def raise_synchronous_exceptions(self, value: bool) -> None: self._commands._raise_synchronous_exceptions = value @property def commands(self) -> CommandsRegistry: """Return the [`CommandsRegistry`][app_model.registries.CommandsRegistry].""" return self._commands @property def menus(self) -> MenusRegistry: """Return the [`MenusRegistry`][app_model.registries.MenusRegistry].""" return self._menus @property def keybindings(self) -> KeyBindingsRegistry: """Return the [`KeyBindingsRegistry`][app_model.registries.KeyBindingsRegistry].""" # noqa E501 return self._keybindings @property def injection_store(self) -> ino.Store: """Return the `in_n_out.Store` instance associated with this `Application`.""" return self._injection_store @property def context(self) -> Context: """Return the [`Context`][app_model.expressions.Context] for this application.""" # noqa E501 return self._context @classmethod def get_or_create(cls, name: str) -> Application: """Get app named `name` or create and return a new one if it doesn't exist.""" return cls._instances[name] if name in cls._instances else cls(name) @classmethod def get_app(cls, name: str) -> Optional[Application]: """Return app named `name` or None if it doesn't exist.""" return cls._instances.get(name) @classmethod def destroy(cls, name: str) -> None: """Destroy the `Application` named `name`. This will call [`dispose()`][app_model.Application.dispose], destroy the injection store, and remove the application from the list of stored application names (allowing the name to be reused). """ if name not in cls._instances: return # pragma: no cover app = cls._instances.pop(name) app.dispose() app.injection_store.destroy(name) app.destroyed.emit(app.name) @property def name(self) -> str: """Return the name of this `Application`.""" return self._name def __repr__(self) -> str: return f"Application({self.name!r})" def dispose(self) -> None: """Dispose this `Application`. This calls all disposers functions (clearing all registries). """ while self._disposers: with contextlib.suppress(Exception): self._disposers.pop()[1]() @overload def register_action(self, action: Action) -> DisposeCallable: ... @overload def register_action( self, action: str, title: str, *, callback: Literal[None] = ..., category: str | None = ..., tooltip: str | None = ..., icon: IconOrDict | None = ..., enablement: Expr | None = ..., menus: list[MenuRuleOrDict] | None = ..., keybindings: list[KeyBindingRuleOrDict] | None = ..., palette: bool = True, ) -> CommandDecorator: ... @overload def register_action( self, action: str, title: str, *, callback: CommandCallable, category: str | None = ..., tooltip: str | None = ..., icon: IconOrDict | None = ..., enablement: Expr | None = ..., menus: list[MenuRuleOrDict] | None = ..., keybindings: list[KeyBindingRuleOrDict] | None = ..., palette: bool = True, ) -> DisposeCallable: ... def register_action( self, action: str | Action, title: str | None = None, *, callback: CommandCallable | None = None, category: str | None = None, tooltip: str | None = None, icon: IconOrDict | None = None, enablement: Expr | None = None, menus: list[MenuRuleOrDict] | None = None, keybindings: list[KeyBindingRuleOrDict] | None = None, palette: bool = True, ) -> CommandDecorator | DisposeCallable: """Register [`Action`][app_model.Action] instance with this application. An [`Action`][app_model.Action] is the complete representation of a command, including information about where and whether it appears in menus and optional keybinding rules. See [`register_action`][app_model.register_action] for complete details on this function. """ if isinstance(action, Action): return register_action(self, action) return register_action( self, id_or_action=action, title=title, # type: ignore callback=callback, # type: ignore category=category, tooltip=tooltip, icon=icon, enablement=enablement, menus=menus, keybindings=keybindings, palette=palette, ) def register_actions(self, actions: Iterable[Action]) -> DisposeCallable: """Register multiple [`Action`][app_model.Action] instances with this app. Returns a function that may be called to undo the registration of `actions`. """ d = [self.register_action(action) for action in actions] def _dispose() -> None: while d: d.pop()() return _dispose app-model-0.3.0/src/app_model/backends/000077500000000000000000000000001467241150700177005ustar00rootroot00000000000000app-model-0.3.0/src/app_model/backends/__init__.py000066400000000000000000000001711467241150700220100ustar00rootroot00000000000000"""Adapters for using the app_model with various backends.""" # TODO: make a `use_app()` like adapter to easily switch? app-model-0.3.0/src/app_model/backends/qt/000077500000000000000000000000001467241150700203245ustar00rootroot00000000000000app-model-0.3.0/src/app_model/backends/qt/__init__.py000066400000000000000000000014661467241150700224440ustar00rootroot00000000000000"""Qt objects for app_model.""" from ._qaction import QCommandAction, QCommandRuleAction, QMenuItemAction from ._qkeybindingedit import QModelKeyBindingEdit from ._qkeymap import ( QKeyBindingSequence, qkey2modelkey, qkeycombo2modelkey, qkeysequence2modelkeybinding, qmods2modelmods, ) from ._qmainwindow import QModelMainWindow from ._qmenu import QModelMenu, QModelMenuBar, QModelSubmenu, QModelToolBar from ._util import to_qicon __all__ = [ "QCommandAction", "QCommandRuleAction", "qkey2modelkey", "QKeyBindingSequence", "qkeycombo2modelkey", "qkeysequence2modelkeybinding", "QMenuItemAction", "QModelKeyBindingEdit", "QModelMainWindow", "QModelMenu", "QModelMenuBar", "QModelSubmenu", "QModelToolBar", "qmods2modelmods", "to_qicon", ] app-model-0.3.0/src/app_model/backends/qt/_qaction.py000066400000000000000000000146341467241150700225030ustar00rootroot00000000000000from __future__ import annotations import contextlib from typing import TYPE_CHECKING, ClassVar, Mapping from weakref import WeakValueDictionary from app_model import Application from app_model.expressions import Expr from app_model.types import ToggleRule from ._qkeymap import QKeyBindingSequence from ._util import to_qicon if TYPE_CHECKING: from PyQt6.QtGui import QAction from qtpy.QtCore import QObject from typing_extensions import Self from app_model.types import CommandRule, MenuItem else: from qtpy.QtWidgets import QAction class QCommandAction(QAction): """Base QAction for a command id. Can execute the command. Parameters ---------- command_id : str Command ID. app : Application | str Application instance or name of application instance. parent : QObject | None Optional parent widget, by default None """ def __init__( self, command_id: str, app: Application | str, parent: QObject | None = None, ): super().__init__(parent) self._app = Application.get_or_create(app) if isinstance(app, str) else app self._command_id = command_id self.setObjectName(command_id) self._keybinding_tooltip = "" if kb := self._app.keybindings.get_keybinding(command_id): self.setShortcut(QKeyBindingSequence(kb.keybinding)) self._keybinding_tooltip = f"({kb.keybinding.to_text()})" self.triggered.connect(self._on_triggered) def _on_triggered(self, checked: bool) -> None: # execute_command returns a Future, for the sake of eventually being # asynchronous without breaking the API. For now, we call result() # to raise any exceptions. self._app.commands.execute_command(self._command_id).result() class QCommandRuleAction(QCommandAction): """QAction for a CommandRule. Parameters ---------- command_rule : CommandRule `CommandRule` instance to create an action for. app : Application | str Application instance or name of application instance. parent : QObject | None Optional parent widget, by default None use_short_title : bool If True, use the `short_title` of the command rule, if it exists. """ def __init__( self, command_rule: CommandRule, app: Application | str, parent: QObject | None = None, *, use_short_title: bool = False, ): super().__init__(command_rule.id, app, parent) self._cmd_rule = command_rule if use_short_title and command_rule.short_title: self.setText(command_rule.short_title) # pragma: no cover else: self.setText(command_rule.title) if command_rule.icon: self.setIcon(to_qicon(command_rule.icon)) self.setIconVisibleInMenu(command_rule.icon_visible_in_menu) if command_rule.tooltip: self.setToolTip(command_rule.tooltip) if command_rule.status_tip: self.setStatusTip(command_rule.status_tip) if command_rule.toggled is not None: self.setCheckable(True) self._refresh() tooltip_with_keybinding = ( f"{self.toolTip()} {self._keybinding_tooltip}".rstrip() ) self.setToolTip(tooltip_with_keybinding) def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled state of this menu item from `ctx`.""" self.setEnabled(expr.eval(ctx) if (expr := self._cmd_rule.enablement) else True) if expr2 := self._cmd_rule.toggled: if ( isinstance(expr2, Expr) or isinstance(expr2, ToggleRule) and (expr2 := expr2.condition) ): self.setChecked(expr2.eval(ctx)) def _refresh(self) -> None: if isinstance(self._cmd_rule.toggled, ToggleRule): if get_current := self._cmd_rule.toggled.get_current: _current = self._app.injection_store.inject( get_current, on_unresolved_required_args="ignore" ) self.setChecked(_current()) class QMenuItemAction(QCommandRuleAction): """QAction for a MenuItem. Mostly the same as a `CommandRuleAction`, but aware of the `menu_item.when` clause to toggle visibility. Parameters ---------- menu_item : MenuItem `MenuItem` instance to create an action for. app : Application | str Application instance or name of application instance. parent : QObject | None Optional parent widget, by default None """ _cache: ClassVar[WeakValueDictionary[tuple[int, int], QMenuItemAction]] = ( WeakValueDictionary() ) def __init__( self, menu_item: MenuItem, app: Application | str, parent: QObject | None = None, ): super().__init__(menu_item.command, app, parent) self._menu_item = menu_item with contextlib.suppress(NameError): self.update_from_context(self._app.context) @staticmethod def _cache_key(app: Application, menu_item: MenuItem) -> tuple[int, int]: return (id(app), hash(menu_item)) @classmethod def create( cls, menu_item: MenuItem, app: Application | str, parent: QObject | None = None, ) -> Self: """Create a new QMenuItemAction for the given menu item. Prefer this method over `__init__` to ensure that the cache is used, so that: ```python a1 = QMenuItemAction.create(action, full_app) a2 = QMenuItemAction.create(action, full_app) a1 is a2 # True ``` """ app = Application.get_or_create(app) if isinstance(app, str) else app cache_key = QMenuItemAction._cache_key(app, menu_item) if cache_key in cls._cache: res = cls._cache[cache_key] res.setParent(parent) return res cls._cache[cache_key] = obj = cls(menu_item, app, parent) return obj def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled/visible state of this menu item from `ctx`.""" super().update_from_context(ctx) self.setVisible(expr.eval(ctx) if (expr := self._menu_item.when) else True) def __repr__(self) -> str: name = self.__class__.__name__ return f"{name}({self._menu_item!r}, app={self._app.name!r})" app-model-0.3.0/src/app_model/backends/qt/_qkeybindingedit.py000066400000000000000000000012561467241150700242130ustar00rootroot00000000000000from typing import TYPE_CHECKING, Optional from qtpy.QtWidgets import QKeySequenceEdit from ._qkeymap import qkeysequence2modelkeybinding if TYPE_CHECKING: from app_model.types import KeyBinding class QModelKeyBindingEdit(QKeySequenceEdit): """Editor for a KeyBinding instance. This is a QKeySequenceEdit with a method that converts the current keySequence to an app_model KeyBinding instance. """ def keyBinding(self) -> Optional["KeyBinding"]: """Return app_model KeyBinding instance for the current keySequence.""" if self.keySequence().isEmpty(): return None return qkeysequence2modelkeybinding(self.keySequence()) app-model-0.3.0/src/app_model/backends/qt/_qkeymap.py000066400000000000000000000407561467241150700225200ustar00rootroot00000000000000# mypy: disable-error-code="operator" from __future__ import annotations import operator from functools import reduce from typing import TYPE_CHECKING, Mapping, MutableMapping from qtpy import API, QT_VERSION from qtpy.QtCore import QCoreApplication, Qt from qtpy.QtGui import QKeySequence from app_model.types import ( KeyBinding, KeyCode, KeyCombo, KeyMod, SimpleKeyBinding, ) from app_model.types._constants import OperatingSystem if TYPE_CHECKING: from qtpy.QtCore import QKeyCombination try: from qtpy import QT6 except ImportError: QT6 = False QCTRL = Qt.KeyboardModifier.ControlModifier QSHIFT = Qt.KeyboardModifier.ShiftModifier QALT = Qt.KeyboardModifier.AltModifier QMETA = Qt.KeyboardModifier.MetaModifier MAC = OperatingSystem.current().is_mac _QMOD_LOOKUP = { "ctrl": QCTRL, "shift": QSHIFT, "alt": QALT, "meta": QMETA, } _SWAPPED_QMOD_LOOKUP = { **_QMOD_LOOKUP, "ctrl": QMETA, "meta": QCTRL, } def _mac_ctrl_meta_swapped() -> bool: """Return True if Qt is swapping Ctrl and Meta for keyboard interactions.""" return not QCoreApplication.testAttribute( Qt.ApplicationAttribute.AA_MacDontSwapCtrlAndMeta ) if QT6: from qtpy.QtCore import QKeyCombination def simple_keybinding_to_qint(skb: SimpleKeyBinding) -> int: """Create Qt Key integer from a SimpleKeyBinding.""" lookup = ( _SWAPPED_QMOD_LOOKUP if MAC and _mac_ctrl_meta_swapped() else _QMOD_LOOKUP ) key = modelkey2qkey(skb.key) if skb.key else Qt.Key.Key_unknown mods = (v for k, v in lookup.items() if getattr(skb, k)) combo = QKeyCombination( reduce(operator.or_, mods, Qt.KeyboardModifier.NoModifier), key ) return int(combo.toCombined()) else: def simple_keybinding_to_qint(skb: SimpleKeyBinding) -> int: """Create Qt Key integer from a SimpleKeyBinding.""" lookup = ( _SWAPPED_QMOD_LOOKUP if MAC and _mac_ctrl_meta_swapped() else _QMOD_LOOKUP ) out = modelkey2qkey(skb.key) if skb.key else 0 mods = (v for k, v in lookup.items() if getattr(skb, k)) out = reduce(operator.or_, mods, out) return int(out) if QT6 and not (API == "pyside6" and int(QT_VERSION[2]) < 4): def _get_qmods(key: QKeyCombination) -> Qt.KeyboardModifier: return key.keyboardModifiers() def _get_qkey(key: QKeyCombination) -> Qt.Key: return key.key() else: def _get_qmods(key: QKeyCombination) -> Qt.KeyboardModifier: return Qt.KeyboardModifier(key & Qt.KeyboardModifier.KeyboardModifierMask) def _get_qkey(key: QKeyCombination) -> Qt.Key: return Qt.Key(key & ~Qt.KeyboardModifier.KeyboardModifierMask) # maybe ~ 1.5x faster than: # QKeySequence.fromString(",".join(str(x) for x in kb.parts)) # but the string version might be more reliable? class QKeyBindingSequence(QKeySequence): """A QKeySequence based on a KeyBinding instance.""" def __init__(self, kb: KeyBinding) -> None: ints = [simple_keybinding_to_qint(skb) for skb in kb.parts] super().__init__(*ints) KEY_TO_QT: dict[KeyCode | None, Qt.Key] = { None: Qt.Key.Key_unknown, KeyCode.UNKNOWN: Qt.Key.Key_unknown, KeyCode.Backquote: Qt.Key.Key_QuoteLeft, KeyCode.Backslash: Qt.Key.Key_Backslash, KeyCode.IntlBackslash: Qt.Key.Key_Backslash, KeyCode.BracketLeft: Qt.Key.Key_BracketLeft, KeyCode.BracketRight: Qt.Key.Key_BracketRight, KeyCode.Comma: Qt.Key.Key_Comma, KeyCode.Digit0: Qt.Key.Key_0, KeyCode.Digit1: Qt.Key.Key_1, KeyCode.Digit2: Qt.Key.Key_2, KeyCode.Digit3: Qt.Key.Key_3, KeyCode.Digit4: Qt.Key.Key_4, KeyCode.Digit5: Qt.Key.Key_5, KeyCode.Digit6: Qt.Key.Key_6, KeyCode.Digit7: Qt.Key.Key_7, KeyCode.Digit8: Qt.Key.Key_8, KeyCode.Digit9: Qt.Key.Key_9, KeyCode.Equal: Qt.Key.Key_Equal, KeyCode.KeyA: Qt.Key.Key_A, KeyCode.KeyB: Qt.Key.Key_B, KeyCode.KeyC: Qt.Key.Key_C, KeyCode.KeyD: Qt.Key.Key_D, KeyCode.KeyE: Qt.Key.Key_E, KeyCode.KeyF: Qt.Key.Key_F, KeyCode.KeyG: Qt.Key.Key_G, KeyCode.KeyH: Qt.Key.Key_H, KeyCode.KeyI: Qt.Key.Key_I, KeyCode.KeyJ: Qt.Key.Key_J, KeyCode.KeyK: Qt.Key.Key_K, KeyCode.KeyL: Qt.Key.Key_L, KeyCode.KeyM: Qt.Key.Key_M, KeyCode.KeyN: Qt.Key.Key_N, KeyCode.KeyO: Qt.Key.Key_O, KeyCode.KeyP: Qt.Key.Key_P, KeyCode.KeyQ: Qt.Key.Key_Q, KeyCode.KeyR: Qt.Key.Key_R, KeyCode.KeyS: Qt.Key.Key_S, KeyCode.KeyT: Qt.Key.Key_T, KeyCode.KeyU: Qt.Key.Key_U, KeyCode.KeyV: Qt.Key.Key_V, KeyCode.KeyW: Qt.Key.Key_W, KeyCode.KeyX: Qt.Key.Key_X, KeyCode.KeyY: Qt.Key.Key_Y, KeyCode.KeyZ: Qt.Key.Key_Z, KeyCode.Minus: Qt.Key.Key_Minus, KeyCode.Period: Qt.Key.Key_Period, KeyCode.Quote: Qt.Key.Key_Apostrophe, KeyCode.Semicolon: Qt.Key.Key_Semicolon, KeyCode.Slash: Qt.Key.Key_Slash, KeyCode.Alt: Qt.Key.Key_Alt, KeyCode.Backspace: Qt.Key.Key_Backspace, KeyCode.CapsLock: Qt.Key.Key_CapsLock, KeyCode.ContextMenu: Qt.Key.Key_Context1, KeyCode.Ctrl: Qt.Key.Key_Control, KeyCode.Enter: Qt.Key.Key_Enter, KeyCode.Meta: Qt.Key.Key_Meta, KeyCode.Shift: Qt.Key.Key_Shift, KeyCode.Space: Qt.Key.Key_Space, KeyCode.Tab: Qt.Key.Key_Tab, KeyCode.Delete: Qt.Key.Key_Delete, KeyCode.End: Qt.Key.Key_End, KeyCode.Home: Qt.Key.Key_Home, KeyCode.Insert: Qt.Key.Key_Insert, KeyCode.PageDown: Qt.Key.Key_PageDown, KeyCode.PageUp: Qt.Key.Key_PageUp, KeyCode.DownArrow: Qt.Key.Key_Down, KeyCode.LeftArrow: Qt.Key.Key_Left, KeyCode.RightArrow: Qt.Key.Key_Right, KeyCode.UpArrow: Qt.Key.Key_Up, KeyCode.NumLock: Qt.Key.Key_NumLock, KeyCode.Numpad0: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_0, KeyCode.Numpad1: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_1, KeyCode.Numpad2: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_2, KeyCode.Numpad3: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_3, KeyCode.Numpad4: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_4, KeyCode.Numpad5: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_5, KeyCode.Numpad6: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_6, KeyCode.Numpad7: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_7, KeyCode.Numpad8: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_8, KeyCode.Numpad9: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_9, KeyCode.NumpadAdd: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Plus, KeyCode.NumpadDecimal: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Period, KeyCode.NumpadDivide: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Slash, KeyCode.NumpadMultiply: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Asterisk, KeyCode.NumpadSubtract: Qt.KeyboardModifier.KeypadModifier | Qt.Key.Key_Minus, KeyCode.Escape: Qt.Key.Key_Escape, KeyCode.F1: Qt.Key.Key_F1, KeyCode.F2: Qt.Key.Key_F2, KeyCode.F3: Qt.Key.Key_F3, KeyCode.F4: Qt.Key.Key_F4, KeyCode.F5: Qt.Key.Key_F5, KeyCode.F6: Qt.Key.Key_F6, KeyCode.F7: Qt.Key.Key_F7, KeyCode.F8: Qt.Key.Key_F8, KeyCode.F9: Qt.Key.Key_F9, KeyCode.F10: Qt.Key.Key_F10, KeyCode.F11: Qt.Key.Key_F11, KeyCode.F12: Qt.Key.Key_F12, KeyCode.PrintScreen: Qt.Key.Key_Print, KeyCode.ScrollLock: Qt.Key.Key_ScrollLock, KeyCode.PauseBreak: Qt.Key.Key_Pause, } KEYMOD_FROM_QT = { Qt.KeyboardModifier.NoModifier: KeyMod.NONE, QALT: KeyMod.Alt, QCTRL: KeyMod.CtrlCmd, QSHIFT: KeyMod.Shift, QMETA: KeyMod.WinCtrl, } MAC_KEYMOD_FROM_QT = {**KEYMOD_FROM_QT, QCTRL: KeyMod.WinCtrl, QMETA: KeyMod.CtrlCmd} KEYMOD_TO_QT = { KeyMod.NONE: Qt.KeyboardModifier.NoModifier, KeyMod.CtrlCmd: QCTRL, KeyMod.Alt: QALT, KeyMod.Shift: QSHIFT, KeyMod.WinCtrl: QMETA, } MAC_KEYMOD_TO_QT = {**KEYMOD_TO_QT, KeyMod.WinCtrl: QCTRL, KeyMod.CtrlCmd: QMETA} KEY_FROM_QT: MutableMapping[Qt.Key, KeyCode | KeyCombo] = { v.toCombined() if hasattr(v, "toCombined") else int(v): k for k, v in KEY_TO_QT.items() if k } # Qt Keys which have no representation in the W3C spec _QTONLY_KEYS: Mapping[Qt.Key, KeyCode | KeyCombo] = { Qt.Key.Key_Exclam: KeyMod.Shift | KeyCode.Digit1, Qt.Key.Key_At: KeyMod.Shift | KeyCode.Digit2, Qt.Key.Key_NumberSign: KeyMod.Shift | KeyCode.Digit3, Qt.Key.Key_Dollar: KeyMod.Shift | KeyCode.Digit4, Qt.Key.Key_Percent: KeyMod.Shift | KeyCode.Digit5, Qt.Key.Key_AsciiCircum: KeyMod.Shift | KeyCode.Digit6, Qt.Key.Key_Ampersand: KeyMod.Shift | KeyCode.Digit7, Qt.Key.Key_Asterisk: KeyMod.Shift | KeyCode.Digit8, Qt.Key.Key_ParenLeft: KeyMod.Shift | KeyCode.Digit9, Qt.Key.Key_ParenRight: KeyMod.Shift | KeyCode.Digit0, Qt.Key.Key_Underscore: KeyMod.Shift | KeyCode.Minus, Qt.Key.Key_Plus: KeyMod.Shift | KeyCode.Equal, Qt.Key.Key_BraceLeft: KeyMod.Shift | KeyCode.BracketLeft, Qt.Key.Key_BraceRight: KeyMod.Shift | KeyCode.BracketRight, Qt.Key.Key_Bar: KeyMod.Shift | KeyCode.Backslash, Qt.Key.Key_Colon: KeyMod.Shift | KeyCode.Semicolon, Qt.Key.Key_QuoteDbl: KeyMod.Shift | KeyCode.Quote, Qt.Key.Key_Less: KeyMod.Shift | KeyCode.Comma, Qt.Key.Key_Greater: KeyMod.Shift | KeyCode.Period, Qt.Key.Key_Question: KeyMod.Shift | KeyCode.Slash, Qt.Key.Key_AsciiTilde: KeyMod.Shift | KeyCode.Backquote, Qt.Key.Key_Return: KeyCode.Enter, Qt.Key.Key_Backtab: KeyMod.Shift | KeyCode.Tab, } KEY_FROM_QT.update(_QTONLY_KEYS) def qmods2modelmods(modifiers: Qt.KeyboardModifier) -> KeyMod: """Return KeyMod from Qt.KeyboardModifier.""" mod = KeyMod.NONE lookup = ( MAC_KEYMOD_FROM_QT if MAC and not _mac_ctrl_meta_swapped() else KEYMOD_FROM_QT ) for modifier in lookup: if modifiers & modifier: mod |= lookup[modifier] return mod def modelkey2qkey(key: KeyCode) -> Qt.Key: """Return Qt.Key from KeyCode.""" if MAC and _mac_ctrl_meta_swapped(): if key == KeyCode.Meta: return Qt.Key.Key_Control if key == KeyCode.Ctrl: return Qt.Key.Key_Meta return KEY_TO_QT.get(key, Qt.Key.Key_unknown) def qkey2modelkey(key: Qt.Key) -> KeyCode | KeyCombo: """Return KeyCode from Qt.Key.""" if MAC and _mac_ctrl_meta_swapped(): if key == Qt.Key.Key_Control: return KeyCode.Meta if key == Qt.Key.Key_Meta: return KeyCode.Ctrl return KEY_FROM_QT.get(key, KeyCode.UNKNOWN) def qkeycombo2modelkey(key: QKeyCombination) -> KeyCode | KeyCombo: """Return KeyCode or KeyCombo from QKeyCombination.""" if key in KEY_FROM_QT: # type ignore because in qt5, key may actually just be int ... but it's fine. return KEY_FROM_QT[key] qmods = _get_qmods(key) qkey = _get_qkey(key) return qmods2modelmods(qmods) | qkey2modelkey(qkey) # type: ignore [return-value] def qkeysequence2modelkeybinding(key: QKeySequence) -> KeyBinding: """Return KeyBinding from QKeySequence.""" # FIXME: this should return KeyChord instead of KeyBinding... but that only takes 2 parts = [SimpleKeyBinding.from_int(qkeycombo2modelkey(x)) for x in iter(key)] return KeyBinding(parts=parts) # ################# These are the Qkeys we currently aren't mapping ################ # # Key_F14 # Key_F15 # Key_F16 # Key_F17 # Key_F18 # Key_F19 # Key_F20 # Key_F21 # Key_F22 # Key_F23 # Key_F24 # Key_F25 # Key_F26 # Key_F27 # Key_F28 # Key_F29 # Key_F30 # Key_F31 # Key_F32 # Key_F33 # Key_F34 # Key_F35 # Key_Super_L # Key_Super_R # Key_Menu # Key_Hyper_L # Key_Hyper_R # Key_Help # Key_Direction_L # Key_Direction_R # Key_nobreakspace # Key_exclamdown # Key_cent # Key_sterling # Key_currency # Key_yen # Key_brokenbar # Key_section # Key_diaeresis # Key_copyright # Key_ordfeminine # Key_guillemotleft # Key_notsign # Key_hyphen # Key_registered # Key_macron # Key_degree # Key_plusminus # Key_twosuperior # Key_threesuperior # Key_acute # Key_mu # Key_paragraph # Key_periodcentered # Key_cedilla # Key_onesuperior # Key_masculine # Key_guillemotright # Key_onequarter # Key_onehalf # Key_threequarters # Key_questiondown # Key_Agrave # Key_Aacute # Key_Acircumflex # Key_Atilde # Key_Adiaeresis # Key_Aring # Key_AE # Key_Ccedilla # Key_Egrave # Key_Eacute # Key_Ecircumflex # Key_Ediaeresis # Key_Igrave # Key_Iacute # Key_Icircumflex # Key_Idiaeresis # Key_ETH # Key_Ntilde # Key_Ograve # Key_Oacute # Key_Ocircumflex # Key_Otilde # Key_Odiaeresis # Key_multiply # Key_Ooblique # Key_Ugrave # Key_Uacute # Key_Ucircumflex # Key_Udiaeresis # Key_Yacute # Key_THORN # Key_ssharp # Key_division # Key_ydiaeresis # Key_AltGr # Key_Multi_key # Key_Codeinput # Key_SingleCandidate # Key_MultipleCandidate # Key_PreviousCandidate # Key_Mode_switch # Key_Kanji # Key_Muhenkan # Key_Henkan # Key_Romaji # Key_Hiragana # Key_Katakana # Key_Hiragana_Katakana # Key_Zenkaku # Key_Hankaku # Key_Zenkaku_Hankaku # Key_Touroku # Key_Massyo # Key_Kana_Lock # Key_Kana_Shift # Key_Eisu_Shift # Key_Eisu_toggle # Key_Hangul # Key_Hangul_Start # Key_Hangul_End # Key_Hangul_Hanja # Key_Hangul_Jamo # Key_Hangul_Romaja # Key_Hangul_Jeonja # Key_Hangul_Banja # Key_Hangul_PreHanja # Key_Hangul_PostHanja # Key_Hangul_Special # Key_Dead_Grave # Key_Dead_Acute # Key_Dead_Circumflex # Key_Dead_Tilde # Key_Dead_Macron # Key_Dead_Breve # Key_Dead_Abovedot # Key_Dead_Diaeresis # Key_Dead_Abovering # Key_Dead_Doubleacute # Key_Dead_Caron # Key_Dead_Cedilla # Key_Dead_Ogonek # Key_Dead_Iota # Key_Dead_Voiced_Sound # Key_Dead_Semivoiced_Sound # Key_Dead_Belowdot # Key_Dead_Hook # Key_Dead_Horn # Key_Dead_Stroke # Key_Dead_Abovecomma # Key_Dead_Abovereversedcomma # Key_Dead_Doublegrave # Key_Dead_Belowring # Key_Dead_Belowmacron # Key_Dead_Belowcircumflex # Key_Dead_Belowtilde # Key_Dead_Belowbreve # Key_Dead_Belowdiaeresis # Key_Dead_Invertedbreve # Key_Dead_Belowcomma # Key_Dead_Currency # Key_Dead_a # Key_Dead_A # Key_Dead_e # Key_Dead_E # Key_Dead_i # Key_Dead_I # Key_Dead_o # Key_Dead_O # Key_Dead_u # Key_Dead_U # Key_Dead_Small_Schwa # Key_Dead_Capital_Schwa # Key_Dead_Greek # Key_Dead_Lowline # Key_Dead_Aboveverticalline # Key_Dead_Belowverticalline # Key_Dead_Longsolidusoverlay # Key_Back # Key_Forward # Key_Stop # Key_Refresh # Key_VolumeDown # Key_VolumeMute # Key_VolumeUp # Key_BassBoost # Key_BassUp # Key_BassDown # Key_TrebleUp # Key_TrebleDown # Key_MediaPlay # Key_MediaStop # Key_MediaPrevious # Key_MediaNext # Key_MediaRecord # Key_MediaPause # Key_MediaTogglePlayPause # Key_HomePage # Key_Favorites # Key_Search # Key_Standby # Key_OpenUrl # Key_LaunchMail # Key_LaunchMedia # Key_Launch0 # Key_Launch1 # Key_Launch2 # Key_Launch3 # Key_Launch4 # Key_Launch5 # Key_Launch6 # Key_Launch7 # Key_Launch8 # Key_Launch9 # Key_LaunchA # Key_LaunchB # Key_LaunchC # Key_LaunchD # Key_LaunchE # Key_LaunchF # Key_MonBrightnessUp # Key_MonBrightnessDown # Key_KeyboardLightOnOff # Key_KeyboardBrightnessUp # Key_KeyboardBrightnessDown # Key_PowerOff # Key_WakeUp # Key_Eject # Key_ScreenSaver # Key_WWW # Key_Memo # Key_LightBulb # Key_Shop # Key_History # Key_AddFavorite # Key_HotLinks # Key_BrightnessAdjust # Key_Finance # Key_Community # Key_AudioRewind # Key_BackForward # Key_ApplicationLeft # Key_ApplicationRight # Key_Book # Key_CD # Key_Calculator # Key_ToDoList # Key_ClearGrab # Key_Close # Key_Copy # Key_Cut # Key_Display # Key_DOS # Key_Documents # Key_Excel # Key_Explorer # Key_Game # Key_Go # Key_iTouch # Key_LogOff # Key_Market # Key_Meeting # Key_MenuKB # Key_MenuPB # Key_MySites # Key_News # Key_OfficeHome # Key_Option # Key_Paste # Key_Phone # Key_Calendar # Key_Reply # Key_Reload # Key_RotateWindows # Key_RotationPB # Key_RotationKB # Key_Save # Key_Send # Key_Spell # Key_SplitScreen # Key_Support # Key_TaskPane # Key_Terminal # Key_Tools # Key_Travel # Key_Video # Key_Word # Key_Xfer # Key_ZoomIn # Key_ZoomOut # Key_Away # Key_Messenger # Key_WebCam # Key_MailForward # Key_Pictures # Key_Music # Key_Battery # Key_Bluetooth # Key_WLAN # Key_UWB # Key_AudioForward # Key_AudioRepeat # Key_AudioRandomPlay # Key_Subtitle # Key_AudioCycleTrack # Key_Time # Key_Hibernate # Key_View # Key_TopMenu # Key_PowerDown # Key_Suspend # Key_ContrastAdjust # Key_LaunchG # Key_LaunchH # Key_TouchpadToggle # Key_TouchpadOn # Key_TouchpadOff # Key_MicMute # Key_Red # Key_Green # Key_Yellow # Key_Blue # Key_ChannelUp # Key_ChannelDown # Key_Guide # Key_Info # Key_Settings # Key_MicVolumeUp # Key_MicVolumeDown # Key_New # Key_Open # Key_Find # Key_Undo # Key_Redo # Key_MediaLast # Key_Select # Key_Yes # Key_No # Key_Cancel # Key_Printer # Key_Execute # Key_Sleep # Key_Play # Key_Zoom # Key_Exit # Key_Context2 # Key_Context3 # Key_Context4 # Key_Call # Key_Hangup # Key_Flip # Key_ToggleCallHangup # Key_VoiceDial # Key_LastNumberRedial # Key_Camera # Key_CameraFocus app-model-0.3.0/src/app_model/backends/qt/_qmainwindow.py000066400000000000000000000031421467241150700233720ustar00rootroot00000000000000from __future__ import annotations from typing import Collection, Mapping, Sequence from qtpy.QtCore import Qt from qtpy.QtWidgets import QMainWindow, QWidget from app_model import Application from ._qmenu import QModelMenuBar, QModelToolBar class QModelMainWindow(QMainWindow): """QMainWindow with app-model support.""" def __init__(self, app: Application | str, parent: QWidget | None = None) -> None: super().__init__(parent) self._app = Application.get_or_create(app) if isinstance(app, str) else app def setModelMenuBar( self, menu_ids: Mapping[str, str] | Sequence[str | tuple[str, str]] ) -> QModelMenuBar: """Set the menu bar to a list of menu ids. Parameters ---------- menu_ids : Mapping[str, str] | Sequence[str | tuple[str, str]] A mapping of menu ids to menu titles or a sequence of menu ids. """ menu_bar = QModelMenuBar(menu_ids, self._app, self) self.setMenuBar(menu_bar) return menu_bar def addModelToolBar( self, menu_id: str, *, exclude: Collection[str] | None = None, area: Qt.ToolBarArea | None = None, toolbutton_style: Qt.ToolButtonStyle = Qt.ToolButtonStyle.ToolButtonIconOnly, ) -> QModelToolBar: """Add a tool bar to the main window.""" toolbar = QModelToolBar(menu_id, self._app, exclude=exclude, parent=self) toolbar.setToolButtonStyle(toolbutton_style) if area is not None: self.addToolBar(area, toolbar) else: self.addToolBar(toolbar) return toolbar app-model-0.3.0/src/app_model/backends/qt/_qmenu.py000066400000000000000000000311601467241150700221630ustar00rootroot00000000000000from __future__ import annotations import contextlib from typing import TYPE_CHECKING, Collection, Iterable, Mapping, Sequence, cast from qtpy.QtWidgets import QMenu, QMenuBar, QToolBar from app_model import Application from app_model.types import SubmenuItem from ._qaction import QCommandRuleAction, QMenuItemAction from ._util import to_qicon try: from qtpy import QT6 except ImportError: QT6 = False if TYPE_CHECKING: from qtpy.QtWidgets import QAction, QWidget class QModelMenu(QMenu): """QMenu for a menu_id in an `app_model` MenusRegistry. Parameters ---------- menu_id : str Menu ID to look up in the registry. app : Application | str Application instance or name of application instance. title : str | None Optional title for the menu, by default None parent : QWidget | None Optional parent widget, by default None """ def __init__( self, menu_id: str, app: Application | str, title: str | None = None, parent: QWidget | None = None, ): QMenu.__init__(self, parent) # NOTE: code duplication with QModelToolBar, but Qt mixins and multiple # inheritance are problematic for some versions of Qt, and for typing assert isinstance(menu_id, str), f"Expected str, got {type(menu_id)!r}" self._menu_id = menu_id self._app = Application.get_or_create(app) if isinstance(app, str) else app self.setObjectName(menu_id) self.rebuild() self._app.menus.menus_changed.connect(self._on_registry_changed) self.destroyed.connect(self._disconnect) # ---------------------- if title is not None: self.setTitle(title) self.aboutToShow.connect(self._on_about_to_show) def findAction(self, object_name: str) -> QAction | QModelMenu | None: """Find an action by its ObjectName. Parameters ---------- object_name : str Action ID to find. Note that `QCommandAction` have `ObjectName` set to their `command.id` """ return _find_action(self.actions(), object_name) def update_from_context( self, ctx: Mapping[str, object], _recurse: bool = True ) -> None: """Update the enabled/visible state of each menu item with `ctx`. See `app_model.expressions` for details on expressions. Parameters ---------- ctx : Mapping A namespace that will be used to `eval()` the `'enablement'` and `'when'` expressions provided for each action in the menu. *ALL variables used in these expressions must either be present in the `ctx` dict, or be builtins*. _recurse : bool recursion check, internal use only """ _update_from_context(self.actions(), ctx, _recurse=_recurse) def rebuild( self, include_submenus: bool = True, exclude: Collection[str] | None = None ) -> None: """Rebuild menu by looking up self._menu_id in menu_registry.""" _rebuild( menu=self, app=self._app, menu_id=self._menu_id, include_submenus=include_submenus, exclude=exclude, ) def _on_about_to_show(self) -> None: for action in self.actions(): if isinstance(action, QCommandRuleAction): action._refresh() def _disconnect(self) -> None: self._app.menus.menus_changed.disconnect(self._on_registry_changed) def _on_registry_changed(self, changed_ids: set[str]) -> None: if self._menu_id in changed_ids: # if this (sub)menu has been removed from the registry, # we may hit a RuntimeError when trying to rebuild it. with contextlib.suppress(RuntimeError): self.rebuild() class QModelSubmenu(QModelMenu): """QMenu for a menu_id in an `app_model` MenusRegistry. Parameters ---------- submenu : SubmenuItem SubmenuItem for which to create a QMenu. app : Application | str Application instance or name of application instance. parent : QWidget | None Optional parent widget, by default None """ def __init__( self, submenu: SubmenuItem, app: Application | str, parent: QWidget | None = None, ): assert isinstance(submenu, SubmenuItem), f"Expected str, got {type(submenu)!r}" self._submenu = submenu super().__init__( menu_id=submenu.submenu, app=app, title=submenu.title, parent=parent ) if submenu.icon: self.setIcon(to_qicon(submenu.icon)) def update_from_context( self, ctx: Mapping[str, object], _recurse: bool = True ) -> None: """Update the enabled state of this menu item from `ctx`.""" super().update_from_context(ctx, _recurse=_recurse) self.setEnabled(expr.eval(ctx) if (expr := self._submenu.enablement) else True) # TODO: ... visibility needs to be controlled at the level of placement # in the submenu. consider only using the `when` expression # self.setVisible(expr.eval(ctx) if (expr := self._submenu.when) else True) class QModelToolBar(QToolBar): """QToolBar that is built from a list of model menu ids. Parameters ---------- menu_id : str Menu ID to look up in the registry. app : Application | str Application instance or name of application instance. exclude : Collection[str] | None Optional list of menu ids to exclude from the toolbar, by default None title : str | None Optional title for the menu, by default None parent : QWidget | None Optional parent widget, by default None """ def __init__( self, menu_id: str, app: Application | str, *, exclude: Collection[str] | None = None, title: str | None = None, parent: QWidget | None = None, ) -> None: self._exclude = exclude QToolBar.__init__(self, parent) # NOTE: code duplication with QModelMenu, but Qt mixins and multiple # inheritance are problematic for some versions of Qt, and for typing assert isinstance(menu_id, str), f"Expected str, got {type(menu_id)!r}" self._menu_id = menu_id self._app = Application.get_or_create(app) if isinstance(app, str) else app self.setObjectName(menu_id) self.rebuild() self._app.menus.menus_changed.connect(self._on_registry_changed) self.destroyed.connect(self._disconnect) # ---------------------- if title is not None: self.setWindowTitle(title) def addMenu(self, menu: QMenu) -> None: """No-op for toolbar.""" def findAction(self, object_name: str) -> QAction | QModelMenu | None: """Find an action by its ObjectName. Parameters ---------- object_name : str Action ID to find. Note that `QCommandAction` have `ObjectName` set to their `command.id` """ return _find_action(self.actions(), object_name) def update_from_context( self, ctx: Mapping[str, object], _recurse: bool = True ) -> None: """Update the enabled/visible state of each menu item with `ctx`. See `app_model.expressions` for details on expressions. Parameters ---------- ctx : Mapping A namespace that will be used to `eval()` the `'enablement'` and `'when'` expressions provided for each action in the menu. *ALL variables used in these expressions must either be present in the `ctx` dict, or be builtins*. _recurse : bool recursion check, internal use only """ _update_from_context(self.actions(), ctx, _recurse=_recurse) def rebuild( self, include_submenus: bool = True, exclude: Collection[str] | None = None ) -> None: """Rebuild toolbar by looking up self._menu_id in menu_registry.""" _rebuild( menu=self, app=self._app, menu_id=self._menu_id, include_submenus=include_submenus, exclude=self._exclude if exclude is None else exclude, ) def _disconnect(self) -> None: self._app.menus.menus_changed.disconnect(self._on_registry_changed) def _on_registry_changed(self, changed_ids: set[str]) -> None: if self._menu_id in changed_ids: self.rebuild() class QModelMenuBar(QMenuBar): """QMenuBar that is built from a list of model menu ids. Parameters ---------- menus : Mapping[str, str] | Sequence[str | tuple[str, str]] A mapping of menu ids to menu titles or a sequence of menu ids. app : Application | str Application instance or name of application instance. parent : QWidget | None Optional parent widget, by default None """ def __init__( self, menus: Mapping[str, str] | Sequence[str | tuple[str, str]], app: Application | str, parent: QWidget | None = None, ) -> None: super().__init__(parent) menu_items = menus.items() if isinstance(menus, Mapping) else menus for item in menu_items: id_, title = item if isinstance(item, tuple) else (item, item.title()) self.addMenu(QModelMenu(id_, app, title, self)) def update_from_context( self, ctx: Mapping[str, object], _recurse: bool = True ) -> None: """Update the enabled/visible state of each menu item with `ctx`. See `app_model.expressions` for details on expressions. Parameters ---------- ctx : Mapping A namespace that will be used to `eval()` the `'enablement'` and `'when'` expressions provided for each action in the menu. *ALL variables used in these expressions must either be present in the `ctx` dict, or be builtins*. _recurse : bool recursion check, internal use only """ _update_from_context(self.actions(), ctx, _recurse=_recurse) def _rebuild( menu: QMenu | QToolBar, app: Application, menu_id: str, include_submenus: bool = True, exclude: Collection[str] | None = None, ) -> None: """Rebuild menu by looking up `menu` in `Application`'s menu_registry.""" actions = menu.actions() for action in actions: menu.removeAction(action) _exclude = exclude or set() groups = list(app.menus.iter_menu_groups(menu_id)) n_groups = len(groups) for n, group in enumerate(groups): for item in group: if isinstance(item, SubmenuItem): if include_submenus: submenu = QModelSubmenu(item, app, parent=menu) cast("QMenu", menu).addMenu(submenu) elif item.command.id not in _exclude: action = QMenuItemAction.create(item, app=app, parent=menu) menu.addAction(action) if n < n_groups - 1: menu.addSeparator() def _update_from_context( actions: Iterable[QAction], ctx: Mapping[str, object], _recurse: bool = True ) -> None: """Update the enabled/visible state of each menu item with `ctx`. See `app_model.expressions` for details on expressions. Parameters ---------- actions : Iterable[QAction] Actions to update. ctx : Mapping A namespace that will be used to `eval()` the `'enablement'` and `'when'` expressions provided for each action in the menu. *ALL variables used in these expressions must either be present in the `ctx` dict, or be builtins*. _recurse : bool recursion check, internal use only """ for action in actions: if isinstance(action, QMenuItemAction): action.update_from_context(ctx) elif not QT6 and isinstance(menu := action.menu(), QModelMenu): menu.update_from_context(ctx) elif isinstance(parent := action.parent(), QModelMenu): # FIXME: this is a hack for Qt6 that I don't entirely understand. # QAction has lost the `.menu()` method, and it's a bit hard to find # how to get to the parent menu now. Checking parent() seems to work, # but I'm not sure if it's the right thing to do, and it leads to a # recursion error. I stop it with the _recurse flag here, but I wonder # whether that will cause other problems. if _recurse: parent.update_from_context(ctx, _recurse=False) def _find_action(actions: Iterable[QAction], object_name: str) -> QAction | None: return next((a for a in actions if a.objectName() == object_name), None) app-model-0.3.0/src/app_model/backends/qt/_util.py000066400000000000000000000012221467241150700220070ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING from qtpy.QtCore import QUrl from qtpy.QtGui import QIcon if TYPE_CHECKING: from typing import Literal from app_model.types import Icon def to_qicon(icon: Icon, theme: Literal["dark", "light"] = "dark") -> QIcon: """Create QIcon from Icon.""" from superqt import QIconifyIcon, fonticon if icn := getattr(icon, theme, ""): if icn.startswith("file://"): return QIcon(QUrl(icn).toLocalFile()) elif ":" in icn: return QIconifyIcon(icn) else: return fonticon.icon(icn) return QIcon() # pragma: no cover app-model-0.3.0/src/app_model/expressions/000077500000000000000000000000001467241150700205105ustar00rootroot00000000000000app-model-0.3.0/src/app_model/expressions/__init__.py000066400000000000000000000012711467241150700226220ustar00rootroot00000000000000"""Abstraction on expressions, and contexts in which to evaluate them.""" from ._context import Context, app_model_context, create_context, get_context from ._context_keys import ContextKey, ContextKeyInfo, ContextNamespace from ._expressions import ( BinOp, BoolOp, Compare, Constant, Expr, IfExp, Name, UnaryOp, parse_expression, safe_eval, ) __all__ = [ "app_model_context", "BinOp", "BoolOp", "Compare", "Constant", "Context", "ContextKey", "ContextKeyInfo", "ContextNamespace", "create_context", "Expr", "get_context", "IfExp", "Name", "parse_expression", "safe_eval", "UnaryOp", ] app-model-0.3.0/src/app_model/expressions/_context.py000066400000000000000000000125421467241150700227110ustar00rootroot00000000000000from __future__ import annotations import os import sys from contextlib import contextmanager from typing import TYPE_CHECKING, Any, Callable, ChainMap, Iterator, MutableMapping from weakref import finalize from psygnal import Signal if TYPE_CHECKING: from types import FrameType from typing import TypedDict class AppModelContextDict(TypedDict): """Global context keys offered by app-model.""" is_linux: bool is_mac: bool is_windows: bool _null = object() class Context(ChainMap): """Evented Mapping of keys to values.""" changed = Signal(set) # Set[str] def __init__(self, *maps: MutableMapping) -> None: super().__init__(*maps) for m in maps: if isinstance(m, Context): m.changed.connect(self.changed) @contextmanager def buffered_changes(self) -> Iterator[None]: """Context in which to accumulated changes before emitting.""" with self.changed.paused(lambda a, b: (a[0].union(b[0]),)): yield def __setitem__(self, k: str, v: Any) -> None: emit = self.get(k, _null) is not v super().__setitem__(k, v) if emit: self.changed.emit({k}) def __delitem__(self, k: str) -> None: emit = k in self super().__delitem__(k) if emit: self.changed.emit({k}) def new_child(self, m: MutableMapping | None = None) -> Context: """Create a new child context from this one.""" new = super().new_child(m=m) self.changed.connect(new.changed) return new def __hash__(self) -> int: return id(self) # note: it seems like WeakKeyDictionary would be a nice match here, but # it appears that the object somehow isn't initialized "enough" to register # as the same object in the WeakKeyDictionary later when queried with # `obj in _OBJ_TO_CONTEXT` ... so instead we use id(obj) # _OBJ_TO_CONTEXT: WeakKeyDictionary[object, Context] = WeakKeyDictionary() _OBJ_TO_CONTEXT: dict[int, Context] = {} _ROOT_CONTEXT: Context | None = None def _pydantic_abort(frame: FrameType) -> bool: # type is being declared and pydantic is checking defaults # this context will never be used. return frame.f_code.co_name in ("__new__", "_set_default_and_type") def create_context( obj: object, max_depth: int = 20, start: int = 2, root: Context | None = None, root_class: type[Context] = Context, frame_predicate: Callable[[FrameType], bool] = _pydantic_abort, ) -> Context: """Create context for any object. Parameters ---------- obj : object Any object max_depth : int, optional Max frame depth to search for another object (that already has a context) off of which to scope this new context. by default 20 start : int, optional first frame to use in search, by default 2 root : Optional[Context], optional Root context to use, by default None root_class : type[Context], optional Root class to use when creating a global root context, by default Context The global context is used when root is None. frame_predicate : Callable[[FrameType], bool], optional Callback that can be used to abort context creation. Will be called on each frame in the stack, and if it returns True, the context will not be created. by default, uses pydantic-specific function to determine if a new pydantic BaseModel is being *declared*, (which means that the context will never be used) `lambda frame: frame.f_code.co_name in ("__new__", "_set_default_and_type")` Returns ------- Optional[Context] Context for the object, or None if no context was found """ if root is None: global _ROOT_CONTEXT if _ROOT_CONTEXT is None: _ROOT_CONTEXT = root_class() root = _ROOT_CONTEXT else: assert isinstance(root, Context), "root must be an instance of Context" parent = root if hasattr(sys, "_getframe"): # CPython implementation detail frame: FrameType | None = sys._getframe(start) i = -1 # traverse call stack looking for another object that has a context # to scope this new context off of. while frame and (i := i + 1) < max_depth: if frame_predicate(frame): return root # pragma: no cover # FIXME: should this be allowed? # FIXME: this might be a bit napari "magic" # it also assumes someone uses "self" as the first argument if "self" in frame.f_locals: _ctx = _OBJ_TO_CONTEXT.get(id(frame.f_locals["self"])) if _ctx is not None: parent = _ctx break frame = frame.f_back new_context = parent.new_child() obj_id = id(obj) _OBJ_TO_CONTEXT[obj_id] = new_context # remove key from dict when object is deleted finalize(obj, lambda: _OBJ_TO_CONTEXT.pop(obj_id, None)) return new_context def get_context(obj: object) -> Context | None: """Return context for any object, if found.""" return _OBJ_TO_CONTEXT.get(id(obj)) def app_model_context() -> AppModelContextDict: """A set of useful global context keys to use.""" return { "is_linux": sys.platform.startswith("linux"), "is_mac": sys.platform == "darwin", "is_windows": os.name == "nt", } app-model-0.3.0/src/app_model/expressions/_context_keys.py000066400000000000000000000161531467241150700237460ustar00rootroot00000000000000from __future__ import annotations import contextlib from types import MappingProxyType from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, Generic, Literal, MutableMapping, NamedTuple, TypeVar, overload, ) from ._expressions import Name if TYPE_CHECKING: import builtins T = TypeVar("T") A = TypeVar("A") class __missing: """Sentinel... done this way for the purpose of typing.""" def __repr__(self) -> str: return "MISSING" MISSING = __missing() class ContextKeyInfo(NamedTuple): """Just a recordkeeping tuple. Retrieve all declared ContextKeys with ContextKeyInfo.info(). """ key: str type: type | None description: str | None namespace: builtins.type[ContextNamespace] | None class ContextKey(Name, Generic[A, T]): """Context key name, default, description, and getter. This is intended to be used as class attribute in a `ContextNamespace`. This is a subclass of `Name`, and is therefore usable in an `Expression`. (see examples.) Parameters ---------- default_value : Any, optional The default value for this key, by default MISSING description : str, optional Description of this key. Useful for documentation, by default None getter : callable, optional Callable that receives an object and retrieves the current value for this key, by default None. For example, if this ContextKey represented the length of some list, (like the layerlist) it might look like `length = ContextKey(0, 'length of the list', lambda x: len(x))` id : str, optional Explicitly provide the `Name` string used when evaluating a context, by default the key will be taken as the attribute name to which this object is assigned as a class attribute: Examples -------- >>> class MyNames(ContextNamespace): ... some_key = ContextKey(0, "some description", lambda x: sum(x)) >>> expr = MyNames.some_key > 5 # create an expression using this key these expressions can be later evaluated with some concrete context. >>> expr.eval({"some_key": 3}) # False >>> expr.eval({"some_key": 6}) # True """ # This will catalog all ContextKeys that get instantiated, which provides # an easy way to organize documentation. # ContextKey.info() returns a list with info for all ContextKeys _info: ClassVar[list[ContextKeyInfo]] = [] MISSING = MISSING def __init__( self, default_value: T | __missing = MISSING, description: str | None = None, getter: Callable[[A], T] | None = None, *, id: str = "", # optional because of __set_name__ ) -> None: super().__init__(id or "") self._default_value = default_value self._getter = getter self._description = description self._owner: type[ContextNamespace] | None = None self._type = ( type(default_value) if default_value not in (None, MISSING) else None ) if id: self._store() def __str__(self) -> str: return self.id @classmethod def info(cls) -> list[ContextKeyInfo]: """Return list of all stored context keys.""" return list(cls._info) def _store(self) -> None: self._info.append( ContextKeyInfo(self.id, self._type, self._description, self._owner) ) def __set_name__(self, owner: type[ContextNamespace[A]], name: str) -> None: """Set the name for this key. (this happens when you instantiate this class as a class attribute). """ if self.id: raise RuntimeError( f"Cannot change id of ContextKey (already {self.id!r})", ) self._owner = owner self.id = name # recompile the code with the new name self._recompile() self._store() @overload def __get__(self, obj: Literal[None], objtype: type) -> ContextKey[A, T]: # When we __get__ from the class, we return ourself ... @overload def __get__(self, obj: ContextNamespace[A], objtype: type) -> T: # When we got from the object, we return the current value ... def __get__( self, obj: ContextNamespace[A] | None, objtype: type ) -> T | ContextKey[A, T] | None: """Get current value of the key in the associated context.""" return self if obj is None else obj._context.get(self.id, MISSING) def __set__(self, obj: ContextNamespace[A], value: T) -> None: """Set current value of the key in the associated context.""" obj._context[self.id] = value def __delete__(self, obj: ContextNamespace[A]) -> None: """Delete key from the associated context.""" del obj._context[self.id] class ContextNamespaceMeta(type): """Metaclass that finds all ContextNamespace members.""" _members_map_: dict[str, ContextKey] def __new__(cls, clsname: str, bases: tuple, attrs: dict) -> ContextNamespaceMeta: """Create a new ContextNamespace class.""" new_cls = super().__new__(cls, clsname, bases, attrs) new_cls._members_map_ = { k: v for k, v in attrs.items() if isinstance(v, ContextKey) } return new_cls @property def __members__(self) -> MappingProxyType[str, ContextKey]: return MappingProxyType(self._members_map_) def __dir__(self) -> list[str]: # pragma: no cover return [ "__class__", "__doc__", "__members__", "__module__", *list(self._members_map_), ] class ContextNamespace(Generic[A], metaclass=ContextNamespaceMeta): """A collection of related keys in a context. meant to be subclassed, with `ContextKeys` as class attributes. """ def __init__(self, context: MutableMapping) -> None: self._context = context # on instantiation we create an index of defaults and value-getters # to speed up retrieval later self._defaults: dict[str, Any] = {} # default values per key self._getters: dict[str, Callable[[A], Any]] = {} # value getters for name, ctxkey in type(self).__members__.items(): self._defaults[name] = ctxkey._default_value if ctxkey._default_value is not MISSING: context[ctxkey.id] = ctxkey._default_value if callable(ctxkey._getter): self._getters[name] = ctxkey._getter def reset(self, key: str) -> None: """Reset keys to its default.""" val = self._defaults[key] if val is MISSING: with contextlib.suppress(KeyError): delattr(self, key) else: setattr(self, key, self._defaults[key]) def reset_all(self) -> None: """Reset all keys to their defaults.""" for key in self._defaults: self.reset(key) def dict(self) -> dict: """Return all keys in this namespace.""" return {k: getattr(self, k) for k in type(self).__members__} def __repr__(self) -> str: import pprint return pprint.pformat(self.dict()) app-model-0.3.0/src/app_model/expressions/_expressions.py000066400000000000000000000510001467241150700235770ustar00rootroot00000000000000"""Provides `Expr` and its subclasses.""" from __future__ import annotations import ast from typing import ( TYPE_CHECKING, Any, Callable, Generic, Iterator, Mapping, Sequence, SupportsIndex, Type, TypeVar, Union, cast, overload, ) ConstType = Union[None, str, bytes, bool, int, float] PassedType = TypeVar( "PassedType", bound=Union[ast.cmpop, ast.operator, ast.boolop, ast.unaryop, ast.expr_context], ) T = TypeVar("T") T2 = TypeVar("T2", bound=Union[ConstType, "Expr"]) V = TypeVar("V", bound=ConstType) if TYPE_CHECKING: from types import CodeType from pydantic.annotated_handlers import GetCoreSchemaHandler from pydantic_core import core_schema from ._context_keys import ContextKey def parse_expression(expr: Expr | str) -> Expr: """Parse string expression into an [`Expr`][app_model.expressions.Expr] instance. Parameters ---------- expr : Expr | str Expression to parse. (If already an `Expr`, it is returned) Returns ------- Expr Instance of `Expr`. Raises ------ SyntaxError If the provided string is not an expression (e.g. it's a statement), or if it uses any forbidden syntax components (e.g. Call, Attribute, Containers, Indexing, Slicing, f-strings, named expression, comprehensions.) """ if isinstance(expr, Expr): return expr try: # mode='eval' means the expr must consist of a single expression tree = ast.parse(str(expr), mode="eval") if not isinstance(tree, ast.Expression): raise SyntaxError # pragma: no cover return ExprTransformer().visit(tree.body) except SyntaxError as e: raise SyntaxError(f"{expr!r} is not a valid expression: ({e}).") from None def safe_eval(expr: str | bool | Expr, context: Mapping | None = None) -> Any: """Safely evaluate `expr` string given `context` dict. This lets you evaluate a string expression with broader expression support than `ast.literal_eval`, but much less support than `eval()`. It also supports booleans (which are returned directly), and `Expr` instances, which are evaluated in the given `context`. Parameters ---------- expr : str | bool | Expr Expression to evaluate. If `expr` is a string, it is parsed into an `Expr` instance. If a `bool`, it is returned directly. context : Mapping | None Context (mapping of names to objects) to evaluate the expression in. """ if isinstance(expr, bool): return expr return parse_expression(expr).eval(context) class Expr(ast.AST, Generic[T]): """Base Expression class providing dunder and convenience methods. This is a subclass of `ast.AST` that provides rich dunder methods that facilitate joining and comparing typed expressions. It only implements a subset of ast Expressions (for safety of evaluation), but provides more than `ast.literal_eval`. Expressions that are supported: - Names: 'myvar' (these must be evaluated along with some context) - Constants: '1' - Comparisons: 'myvar > 1' - Boolean Operators: 'myvar & yourvar' (bitwise `&` and `|` are overloaded here to mean boolean `and` and `or`) - Binary Operators: 'myvar + 42' (includes `//`, `@`, `^`) - Unary Operators: 'not myvar' Things that are *NOT* supported: - attribute access: 'my.attr' - calls: 'f(x)' - containers (lists, tuples, sets, dicts) - indexing or slicing - joined strings (f-strings) - named expressions (walrus operator) - comprehensions (list, set, dict, generator) - statements & assignments (e.g. 'a = b') This class is not meant to be instantiated directly. Instead, use [`parse_expression`][app_model.expressions._expressions.parse_expression], or the [`Expr.parse`][app_model.expressions.Expr.parse] classmethod to create an expression instance. Once created, an expression can be joined with other expressions, or constants. Examples -------- >>> expr = parse_expression("myvar > 5") combine expressions with operators >>> new_expr = expr & parse_expression("v2") nice `repr` >>> new_expr BoolOp( op=And(), values=[ Compare( left=Name(id='myvar', ctx=Load()), ops=[ Gt()], comparators=[ Constant(value=5)]), Name(id='v2', ctx=Load())]) evaluate in some context >>> new_expr.eval(dict(v2="hello!", myvar=8)) 'hello!' you can also use keyword arguments. This is *slightly* slower >>> new_expr.eval(v2="hello!", myvar=4) serialize >>> str(new_expr) 'myvar > 5 and v2' One reason you might want to use this object is to capture named expressions that can be evaluated repeatedly as some underlying context changes. ```python light_is_green = Name[bool]("light_is_green") count = Name[int]("count") is_ready = light_is_green & count > 5 assert is_ready.eval({"count": 4, "light_is_green": True}) == False assert is_ready.eval({"count": 7, "light_is_green": False}) == False assert is_ready.eval({"count": 7, "light_is_green": True}) == True ``` this will also preserve type information: >>> reveal_type(is_ready()) # revealed type is `bool` """ _names: set[str] _code: CodeType def __init__(self, *args: Any, **kwargs: Any) -> None: if type(self).__name__ == "Expr": raise RuntimeError("Don't instantiate Expr. Use `Expr.parse`") super().__init__(*args, **kwargs) self._recompile() def _recompile(self) -> None: ast.fix_missing_locations(self) self._code = compile(ast.Expression(body=self), "", "eval") # type: ignore [arg-type] self._names = set(self._iter_names()) def eval( self, context: Mapping[str, object] | None = None, **ctx_kwargs: object ) -> T: """Evaluate this expression with names in `context`.""" if context is None: context = ctx_kwargs elif ctx_kwargs: context = {**context, **ctx_kwargs} try: return eval(self._code, {}, context) # type: ignore except NameError as e: miss = {k for k in self._names if k not in context} raise NameError( f"Names required to eval this expression are missing: {miss}" ) from e @classmethod def parse(cls, expr: str) -> Expr: """Parse string into Expr (classmethod). see docstring of [`parse_expression`][app_model.expressions.parse_expression] for details. """ return parse_expression(expr) def __str__(self) -> str: """Serialize this expression to string form.""" return self._serialize() def _serialize(self) -> str: """Serialize this expression to string form.""" return str(_ExprSerializer(self)) def __repr__(self) -> str: return f"Expr.parse({str(self)!r})" @staticmethod def _cast(obj: Any) -> Expr: """Cast object into an Expression.""" return obj if isinstance(obj, Expr) else Constant(obj) # boolean operators # '&' and '|' are normally binary operators... but we use them here to # combine expression objects meaning "and" and "or". # if you want the binary operators, use Expr.bitand, and Expr.bitor def __and__( self, other: Expr[T2] | Expr[T] | ConstType | Compare ) -> BoolOp[T | T2]: return BoolOp(ast.And(), [self, other]) def __or__(self, other: Expr[T2] | Expr[T] | ConstType | Compare) -> BoolOp[T | T2]: return BoolOp(ast.Or(), [self, other]) # comparisons def __lt__(self, other: Any) -> Compare: return Compare(self, [ast.Lt()], [other]) def __le__(self, other: Any) -> Compare: return Compare(self, [ast.LtE()], [other]) def __eq__(self, other: Any) -> Compare: # type: ignore return Compare(self, [ast.Eq()], [other]) def __ne__(self, other: Any) -> Compare: # type: ignore return Compare(self, [ast.NotEq()], [other]) def __gt__(self, other: Any) -> Compare: return Compare(self, [ast.Gt()], [other]) def __ge__(self, other: Any) -> Compare: return Compare(self, [ast.GtE()], [other]) # using __contains__ always returns a bool... so we provide our own # Expr.in_ and Expr.not_in methods def in_(self, other: Any) -> Compare: """Return a comparison for `self` in `other`.""" # not a dunder, use with Expr.in_(a, other) return Compare(self, [ast.In()], [other]) def not_in(self, other: Any) -> Compare: """Return a comparison for `self` no in `other`.""" return Compare(self, [ast.NotIn()], [other]) # binary operators # (note that __and__ and __or__ are reserved for boolean operators.) def __add__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Add(), other) def __sub__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Sub(), other) def __mul__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Mult(), other) def __truediv__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Div(), other) def __floordiv__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.FloorDiv(), other) def __mod__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Mod(), other) def __matmul__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.MatMult(), other) # pragma: no cover def __pow__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.Pow(), other) def __xor__(self, other: T | Expr[T]) -> BinOp[T]: return BinOp(self, ast.BitXor(), other) def bitand(self, other: T | Expr[T]) -> BinOp[T]: """Return bitwise self & other.""" return BinOp(self, ast.BitAnd(), other) def bitor(self, other: T | Expr[T]) -> BinOp[T]: """Return bitwise self | other.""" return BinOp(self, ast.BitOr(), other) # unary operators def __neg__(self) -> UnaryOp[T]: return UnaryOp(ast.USub(), self) def __pos__(self) -> UnaryOp[T]: # usually a no-op return UnaryOp(ast.UAdd(), self) def __invert__(self) -> UnaryOp[T]: # note: we're using the invert operator `~` to mean "not ___" return UnaryOp(ast.Not(), self) def __reduce_ex__(self, protocol: SupportsIndex) -> tuple[Any, ...]: rv = list(super().__reduce_ex__(protocol)) rv[1] = tuple(getattr(self, f) for f in self._fields) return tuple(rv) @classmethod def __get_validators__(cls) -> Iterator[Callable[[Any], Expr]]: """Pydantic validators for this class.""" yield cls._validate @classmethod def __get_pydantic_core_schema__( cls, source: type, handler: GetCoreSchemaHandler ) -> core_schema.CoreSchema: # this will only be called by pydantic v2 from pydantic_core import core_schema return core_schema.no_info_plain_validator_function(cls._validate) @classmethod def _validate(cls, v: Any) -> Expr: """Validate v as an `Expr`. For use with Pydantic.""" return v if isinstance(v, Expr) else parse_expression(v) def __hash__(self) -> int: _hash = hash(self.__class__) for f in self._fields: field = getattr(self, f) if isinstance(field, list): field = tuple(field) _hash += hash(field) return _hash def _iter_names(self) -> Iterator[str]: yield from _iter_names(self) LOAD = ast.Load() class Name(Expr[T], ast.Name): """A variable name. `id` holds the name as a string. """ def __init__(self, id: str, ctx: ast.expr_context = LOAD, **kwargs: Any) -> None: kwargs["ctx"] = LOAD super().__init__(id, **kwargs) class Constant(Expr[V], ast.Constant): """A constant value. The `value` attribute contains the Python object it represents. types supported: NoneType, str, bytes, bool, int, float """ value: V def __init__(self, value: V, kind: str | None = None, **kwargs: Any) -> None: _valid_type = (type(None), str, bytes, bool, int, float) if not isinstance(value, _valid_type): raise TypeError(f"Constants must be type: {_valid_type!r}") super().__init__(value, kind, **kwargs) class Compare(Expr[bool], ast.Compare): """A comparison of two or more values. `left` is the first value in the comparison, `ops` the list of operators, and `comparators` the list of values after the first element in the comparison. """ def __init__( self, left: Expr, ops: Sequence[ast.cmpop], comparators: Sequence[Expr], **kwargs: Any, ) -> None: super().__init__( Expr._cast(left), ops, [Expr._cast(c) for c in comparators], **kwargs, ) class BinOp(Expr[T], ast.BinOp): """A binary operation (like addition or division). `op` is the operator, and `left` and `right` are any expression nodes. """ def __init__( self, left: T | Expr[T], op: ast.operator, right: T | Expr[T], **k: Any, ) -> None: super().__init__(Expr._cast(left), op, Expr._cast(right), **k) class BoolOp(Expr[T], ast.BoolOp): """A boolean operation, 'or' or 'and'. `op` is Or or And. `values` are the values involved. Consecutive operations with the same operator, such as a or b or c, are collapsed into one node with several values. This doesn't include `not`, which is a `UnaryOp`. """ def __init__( self, op: ast.boolop, values: Sequence[ConstType | Expr], **kwargs: Any, ): super().__init__(op, [Expr._cast(v) for v in values], **kwargs) class UnaryOp(Expr[T], ast.UnaryOp): """A unary operation. `op` is the operator, and `operand` any expression node. """ def __init__(self, op: ast.unaryop, operand: Expr, **kwargs: Any) -> None: super().__init__(op, Expr._cast(operand), **kwargs) class IfExp(Expr, ast.IfExp): """An expression such as `'a if b else c'`. `body` if `test` else `orelse` """ def __init__(self, test: Expr, body: Expr, orelse: Expr, **kwargs: Any) -> None: super().__init__( Expr._cast(test), Expr._cast(body), Expr._cast(orelse), **kwargs ) class Tuple(Expr, ast.Tuple): """A tuple expression. `elts` is a list of expressions. """ def __init__( self, elts: Sequence[Expr], ctx: ast.expr_context = LOAD, **kwargs: Any ) -> None: kwargs["ctx"] = ctx super().__init__(elts=[Expr._cast(e) for e in elts], **kwargs) class List(Expr, ast.List): """A tuple expression. `elts` is a list of expressions. """ def __init__( self, elts: Sequence[Expr], ctx: ast.expr_context = LOAD, **kwargs: Any ) -> None: kwargs["ctx"] = ctx super().__init__(elts=[Expr._cast(e) for e in elts], **kwargs) class Set(Expr, ast.Set): """A tuple expression. `elts` is a list of expressions. """ def __init__( self, elts: Sequence[Expr], ctx: ast.expr_context = LOAD, **kwargs: Any ) -> None: kwargs["ctx"] = ctx super().__init__(elts=[Expr._cast(e) for e in elts], **kwargs) class ExprTransformer(ast.NodeTransformer): """Transformer that converts an ast.expr into an `Expr`. Examples -------- >>> tree = ast.parse("my_var > 11", mode="eval") >>> tree = ExprTransformer().visit(tree) # transformed """ _SUPPORTED_NODES = frozenset( k for k, v in globals().items() if isinstance(v, type) and issubclass(v, Expr) ) # fmt: off @overload def visit(self, node: ast.expr) -> Expr: ... @overload def visit(self, node: PassedType) -> PassedType: ... # fmt: on def visit(self, node: ast.AST) -> ast.AST | None: """Visit a node in the tree, transforming into Expr.""" if isinstance( node, ( ast.cmpop, ast.operator, ast.boolop, ast.unaryop, ast.expr_context, ), ): # all operation types just get passed through return node # filter here for supported expression node types type_ = type(node).__name__ if type_ not in ExprTransformer._SUPPORTED_NODES: raise SyntaxError(f"Type {type_!r} not supported") # providing fake lineno and col_offset here rather than using # ast.fill_missing_locations for typing purposes kwargs: dict[str, Any] = {"lineno": 1, "col_offset": 0} for name, field in ast.iter_fields(node): if isinstance(field, ast.expr): kwargs[name] = self.visit(field) elif isinstance(field, list): kwargs[name] = [self.visit(item) for item in field] else: kwargs[name] = field # return instance of Expr from this module corresponding to the node type return cast(Expr, globals()[type_](**kwargs)) class _ExprSerializer(ast.NodeVisitor): """Serializes an `Expr` into a string. Examples -------- >>> expr = Expr.parse("a + b == c") >>> print(expr) 'a + b == c' or ... using this visitor directly: >>> serializer = ExprSerializer() >>> serializer.visit(expr) >>> out = "".join(serializer.result) """ def __init__(self, node: Expr | None = None) -> None: self._result: list[str] = [] def write(*params: ast.AST | str) -> None: for item in params: if isinstance(item, ast.AST): self.visit(item) elif item: self._result.append(item) self.write = write if node is not None: self.visit(node) def __str__(self) -> str: return "".join(self._result) def visit_Name(self, node: ast.Name) -> None: self.write(node.id) def visit_Tuple(self, node: ast.Tuple) -> None: self.write(f'({", ".join(map(str, node.elts))})') def visit_Set(self, node: ast.Set) -> None: self.write("{" + ", ".join(map(str, node.elts)) + "}") def visit_List(self, node: ast.List) -> None: self.write(f'[{", ".join(map(str, node.elts))}]') def visit_ContextKey(self, node: ContextKey) -> None: return self.visit_Name(node) def visit_Constant(self, node: ast.Constant) -> None: self.write(repr(node.value)) def visit_BoolOp(self, node: ast.BoolOp) -> Any: op = f" {_OPS[type(node.op)]} " for idx, value in enumerate(node.values): self.write(idx and op or "", value) def visit_Compare(self, node: ast.Compare) -> None: self.visit(node.left) for op, right in zip(node.ops, node.comparators): self.write(f" {_OPS[type(op)]} ", right) def visit_BinOp(self, node: ast.BinOp) -> None: self.write(node.left, f" {_OPS[type(node.op)]} ", node.right) def visit_UnaryOp(self, node: ast.UnaryOp) -> None: sym = _OPS[type(node.op)] self.write(sym, " " if sym.isalpha() else "", node.operand) def visit_IfExp(self, node: ast.IfExp) -> Any: self.write(node.body, " if ", node.test, " else ", node.orelse) OpType = Union[Type[ast.operator], Type[ast.cmpop], Type[ast.boolop], Type[ast.unaryop]] _OPS: dict[OpType, str] = { # ast.boolop ast.Or: "or", ast.And: "and", # ast.cmpop ast.Eq: "==", ast.Gt: ">", ast.GtE: ">=", ast.In: "in", ast.Is: "is", ast.NotEq: "!=", ast.Lt: "<", ast.LtE: "<=", ast.NotIn: "not in", ast.IsNot: "is not", # ast.operator ast.BitOr: "|", ast.BitXor: "^", ast.BitAnd: "&", ast.LShift: "<<", ast.RShift: ">>", ast.Add: "+", ast.Sub: "-", ast.Mult: "*", ast.Div: "/", ast.Mod: "%", ast.FloorDiv: "//", ast.MatMult: "@", ast.Pow: "**", # ast.unaryop ast.Not: "not", ast.Invert: "~", ast.UAdd: "+", ast.USub: "-", } def _iter_names(expr: Expr) -> Iterator[str]: """Iterate all (nested) names used in the expression. Could be used to provide nicer error messages when eval() fails. """ if isinstance(expr, Name): yield expr.id elif isinstance(expr, Expr): for _, val in ast.iter_fields(expr): val = val if isinstance(val, list) else [val] for v in val: yield from _iter_names(v) app-model-0.3.0/src/app_model/py.typed000066400000000000000000000000001467241150700176130ustar00rootroot00000000000000app-model-0.3.0/src/app_model/registries/000077500000000000000000000000001467241150700203065ustar00rootroot00000000000000app-model-0.3.0/src/app_model/registries/__init__.py000066400000000000000000000006101467241150700224140ustar00rootroot00000000000000"""App-model registries, such as menus, keybindings, commands.""" from ._commands_reg import CommandsRegistry, RegisteredCommand from ._keybindings_reg import KeyBindingsRegistry from ._menus_reg import MenusRegistry from ._register import register_action __all__ = [ "CommandsRegistry", "KeyBindingsRegistry", "MenusRegistry", "register_action", "RegisteredCommand", ] app-model-0.3.0/src/app_model/registries/_commands_reg.py000066400000000000000000000201501467241150700234530ustar00rootroot00000000000000from __future__ import annotations from concurrent.futures import Future, ThreadPoolExecutor from typing import TYPE_CHECKING, Any, Callable, Generic, Iterator, TypeVar, cast from in_n_out import Store from psygnal import Signal # maintain runtime compatibility with older typing_extensions if TYPE_CHECKING: from typing_extensions import ParamSpec from app_model.types import Action, DisposeCallable P = ParamSpec("P") else: try: from typing_extensions import ParamSpec P = ParamSpec("P") except ImportError: P = TypeVar("P") R = TypeVar("R") class RegisteredCommand(Generic[P, R]): """Small object to represent a command in the CommandsRegistry. Used internally by the CommandsRegistry. This helper class allows us to cache the dependency-injected variant of the command, so that type resolution and dependency injection is performed only once. """ __slots__ = ( "id", "callback", "title", "_resolved_callback", "_injection_store", "_injected_callback", "_initialized", ) def __init__( self, id: str, callback: Callable[P, R] | str, title: str, store: Store | None = None, ) -> None: self.id = id self.callback = callback self.title = title self._injection_store: Store = store or Store.get_store() self._resolved_callback = callback if callable(callback) else None self._injected_callback: Callable[P, R] | None = None self._initialized = True def __setattr__(self, name: str, value: Any) -> None: """Object is immutable after initialization.""" if getattr(self, "_initialized", False): raise AttributeError("RegisteredCommand object is immutable.") super().__setattr__(name, value) @property def resolved_callback(self) -> Callable[P, R]: """Return the resolved command callback. This property is cached, so the callback types are only resolved once. """ if self._resolved_callback is None: from app_model.types._utils import import_python_name try: cb = import_python_name(str(self.callback)) except ImportError as e: object.__setattr__(self, "_resolved_callback", lambda *a, **k: None) raise type(e)( f"Command pointer {self.callback!r} registered for Command " f"{self.id!r} was not importable: {e}" ) from e if not callable(cb): # don't try to import again, just create a no-op object.__setattr__(self, "_resolved_callback", lambda *a, **k: None) raise TypeError( f"Command pointer {self.callback!r} registered for Command " f"{self.id!r} did not resolve to a callble object." ) object.__setattr__(self, "_resolved_callback", cb) return cast("Callable[P, R]", self._resolved_callback) @property def run_injected(self) -> Callable[P, R]: """Return the command callback with dependencies injected. This property is cached, so the injected version is only created once. """ if self._injected_callback is None: cb = self._injection_store.inject(self.resolved_callback, processors=True) object.__setattr__(self, "_injected_callback", cb) return cast("Callable[P, R]", self._injected_callback) class CommandsRegistry: """Registry for commands (callable objects).""" registered = Signal(str) def __init__( self, injection_store: Store | None = None, raise_synchronous_exceptions: bool = False, ) -> None: self._commands: dict[str, RegisteredCommand] = {} self._injection_store = injection_store self._raise_synchronous_exceptions = raise_synchronous_exceptions def register_action(self, action: Action) -> DisposeCallable: """Register an Action object. This is a convenience method that registers the action's callback with the action's ID and title using `register_command`. Parameters ---------- action: Action Action to register Returns ------- DisposeCallable A function that can be called to unregister the action. """ return self.register_command(action.id, action.callback, action.title) def register_command( self, id: str, callback: Callable[P, R] | str, title: str ) -> DisposeCallable: """Register a callable as the handler for command `id`. Parameters ---------- id : CommandId Command identifier callback : Callable Callable to be called when the command is executed title : str Title for the command. Returns ------- DisposeCallable A function that can be called to unregister the command. """ if id in self._commands: raise ValueError( f"Command {id!r} already registered with callback " f"{self._commands[id].callback!r} (new callback: {callback!r})" ) cmd = RegisteredCommand(id, callback, title, self._injection_store) self._commands[id] = cmd def _dispose() -> None: self._commands.pop(id, None) self.registered.emit(id) return _dispose def __iter__(self) -> Iterator[tuple[str, RegisteredCommand]]: yield from self._commands.items() def __len__(self) -> int: return len(self._commands) def __contains__(self, id: str) -> bool: return id in self._commands def __repr__(self) -> str: name = self.__class__.__name__ return f"<{name} at {hex(id(self))} ({len(self._commands)} commands)>" def __getitem__(self, id: str) -> RegisteredCommand: """Retrieve commands registered under a given ID.""" if id not in self._commands: raise KeyError(f"Command {id!r} not registered") return self._commands[id] def execute_command( self, id: str, *args: Any, execute_asynchronously: bool = False, **kwargs: Any, ) -> Future: """Execute a registered command. Parameters ---------- id : CommandId ID of the command to execute *args: Any Positional arguments to pass to the command execute_asynchronously : bool Whether to execute the command asynchronously in a thread, by default `False`. Note that *regardless* of this setting, the return value will implement the `Future` API (so it's necessary) to call `result()` on the returned object. Eventually, this will default to True, but we need to solve `ensure_main_thread` Qt threading issues first **kwargs: Any Keyword arguments to pass to the command Returns ------- Future: concurrent.futures.Future Future object containing the result of the command Raises ------ KeyError If the command is not registered or has no callbacks. """ try: cmd = self[id].run_injected except KeyError as e: raise KeyError(f"Command {id!r} not registered") from e # pragma: no cover if execute_asynchronously: with ThreadPoolExecutor() as executor: return executor.submit(cmd, *args, **kwargs) future: Future = Future() try: future.set_result(cmd(*args, **kwargs)) except Exception as e: if self._raise_synchronous_exceptions: # note, the caller of this function can also achieve this by # calling `future.result()` on the returned future object. raise e future.set_exception(e) return future def __str__(self) -> str: lines = [f"{id_!r:<32} -> {cmd.title!r}" for id_, cmd in self] return "\n".join(lines) app-model-0.3.0/src/app_model/registries/_keybindings_reg.py000066400000000000000000000121301467241150700241570ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Callable, NamedTuple from psygnal import Signal from app_model.types import KeyBinding if TYPE_CHECKING: from typing import Iterator, TypeVar from app_model import expressions from app_model.types import Action, DisposeCallable, KeyBindingRule CommandDecorator = Callable[[Callable], Callable] CommandCallable = TypeVar("CommandCallable", bound=Callable) class _RegisteredKeyBinding(NamedTuple): """Internal object representing a fully registered keybinding.""" keybinding: KeyBinding # the keycode to bind to command_id: str # the command to run weight: int # the weight of the binding, for prioritization when: expressions.Expr | None = None # condition to enable keybinding class KeyBindingsRegistry: """Registry for keybindings. Attributes ---------- filter_keybinding : Callable[[KeyBinding], str] | None Optional function for applying additional `KeyBinding` filtering. Callable should accept a `KeyBinding` object and return an error message (`str`) if `KeyBinding` is rejected, or empty string otherwise. """ registered = Signal() def __init__(self) -> None: self._keybindings: list[_RegisteredKeyBinding] = [] self._filter_keybinding: Callable[[KeyBinding], str] | None = None @property def filter_keybinding(self) -> Callable[[KeyBinding], str] | None: """Return the `filter_keybinding`.""" return self._filter_keybinding @filter_keybinding.setter def filter_keybinding(self, value: Callable[[KeyBinding], str] | None) -> None: if callable(value) or value is None: self._filter_keybinding = value else: raise TypeError("'filter_keybinding' must be a callable or None") def register_action_keybindings(self, action: Action) -> DisposeCallable | None: """Register all keybindings declared in `action.keybindings`. Parameters ---------- action : Action The action to register keybindings for. Returns ------- DisposeCallable | None A function that can be called to unregister the keybindings. If no keybindings were registered, returns None. """ if not (keybindings := action.keybindings): return None disposers: list[Callable[[], None]] = [] msg: list[str] = [] for keyb in keybindings: if action.enablement is not None: kwargs = keyb.model_dump() kwargs["when"] = ( action.enablement if keyb.when is None else action.enablement | keyb.when ) _keyb = type(keyb)(**kwargs) else: _keyb = keyb try: if d := self.register_keybinding_rule(action.id, _keyb): disposers.append(d) except ValueError as e: msg.append(str(e)) if msg: raise ValueError( "The following keybindings were not valid:\n" + "\n".join(msg) ) if not disposers: # pragma: no cover return None def _dispose() -> None: for disposer in disposers: disposer() return _dispose def register_keybinding_rule( self, id: str, rule: KeyBindingRule ) -> DisposeCallable | None: """Register a new keybinding rule. Parameters ---------- id : str Command identifier that should be run when the keybinding is triggered rule : KeyBindingRule KeyBinding information Returns ------- Optional[DisposeCallable] A callable that can be used to unregister the keybinding """ if plat_keybinding := rule._bind_to_current_platform(): keybinding = KeyBinding.validate(plat_keybinding) if self._filter_keybinding: msg = self._filter_keybinding(keybinding) if msg: raise ValueError(f"{keybinding}: {msg}") entry = _RegisteredKeyBinding( keybinding=keybinding, command_id=id, weight=rule.weight, when=rule.when, ) self._keybindings.append(entry) self.registered.emit() def _dispose() -> None: self._keybindings.remove(entry) return _dispose return None # pragma: no cover def __iter__(self) -> Iterator[_RegisteredKeyBinding]: yield from self._keybindings def __repr__(self) -> str: name = self.__class__.__name__ return f"<{name} at {hex(id(self))} ({len(self._keybindings)} bindings)>" def get_keybinding(self, key: str) -> _RegisteredKeyBinding | None: """Return the first keybinding that matches the given command ID.""" # TODO: improve me. return next( (entry for entry in self._keybindings if entry.command_id == key), None ) app-model-0.3.0/src/app_model/registries/_menus_reg.py000066400000000000000000000135721467241150700230130ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, Any, Callable, Final, Iterable, Iterator from psygnal import Signal from app_model.types import MenuItem if TYPE_CHECKING: from app_model.types import Action, DisposeCallable, MenuOrSubmenu MenuId = str class MenusRegistry: """Registry for menu and submenu items.""" COMMAND_PALETTE_ID: Final = "_command_pallet_" menus_changed = Signal(set) def __init__(self) -> None: self._menu_items: dict[MenuId, dict[MenuOrSubmenu, None]] = {} def append_action_menus(self, action: Action) -> DisposeCallable | None: """Append all MenuRule items declared in `action.menus`. Parameters ---------- action : Action The action containing menus to append. Returns ------- DisposeCallable | None A function that can be called to unregister the menu items. If no menu items were registered, returns `None`. """ disposers: list[Callable[[], None]] = [] disp1 = self.append_menu_items( ( rule.id, MenuItem( command=action, when=rule.when, group=rule.group, order=rule.order ), ) for rule in action.menus or () ) disposers.append(disp1) if action.palette: menu_item = MenuItem(command=action, when=action.enablement) disp = self.append_menu_items([(self.COMMAND_PALETTE_ID, menu_item)]) disposers.append(disp) if not disposers: # pragma: no cover return None def _dispose() -> None: for disposer in disposers: disposer() return _dispose def append_menu_items( self, items: Iterable[tuple[MenuId, MenuOrSubmenu]] ) -> DisposeCallable: """Append menu items to the registry. Parameters ---------- items : Iterable[Tuple[str, MenuOrSubmenu]] Items to append. Returns ------- DisposeCallable A function that can be called to unregister the menu items. """ changed_ids: set[str] = set() disposers: list[Callable[[], None]] = [] for menu_id, item in items: item = MenuItem._validate(item) # type: ignore menu_dict = self._menu_items.setdefault(menu_id, {}) menu_dict[item] = None changed_ids.add(menu_id) def _remove(dct: dict = menu_dict, _item: Any = item) -> None: dct.pop(_item, None) disposers.append(_remove) def _dispose() -> None: for disposer in disposers: disposer() for id_ in changed_ids: if not self._menu_items.get(id_): del self._menu_items[id_] self.menus_changed.emit(changed_ids) if changed_ids: self.menus_changed.emit(changed_ids) return _dispose def __iter__( self, ) -> Iterator[tuple[MenuId, Iterable[MenuOrSubmenu]]]: yield from self._menu_items.items() def __contains__(self, id: object) -> bool: return id in self._menu_items def get_menu(self, menu_id: MenuId) -> list[MenuOrSubmenu]: """Return menu items for `menu_id`.""" # using method rather than __getitem__ so that subclasses can use arguments return list(self._menu_items[menu_id]) def __repr__(self) -> str: name = self.__class__.__name__ return f"<{name} at {hex(id(self))} ({len(self._menu_items)} menus)>" def __str__(self) -> str: return "\n".join(self._render()) def _render(self) -> list[str]: """Return registered menu items as lines of strings.""" # this is mostly here as a debugging tool. Can be removed or improved later. lines: list[str] = [] branch = " ├──" for menu in self._menu_items: lines.append(menu) for group in self.iter_menu_groups(menu): first = next(iter(group)) lines.append(f" ├───────────{first.group}───────────────") for child in group: if isinstance(child, MenuItem): lines.append( f"{branch} {child.command.title} ({child.command.id})" ) else: lines.extend( [ f"{branch} {child.submenu}", " ├── └── ...", ] ) lines.append("") return lines def iter_menu_groups(self, menu_id: MenuId) -> Iterator[list[MenuOrSubmenu]]: """Iterate over menu groups for `menu_id`. Groups are broken into sections (lists of menu or submenu items) based on their `group` attribute. And each group is sorted by `order` attribute. Parameters ---------- menu_id : str The menu ID to return groups for. Yields ------ Iterator[List[MenuOrSubmenu]] Iterator of menu/submenu groups. """ if menu_id in self: yield from _sort_groups(self.get_menu(menu_id)) def _sort_groups( items: list[MenuOrSubmenu], group_key: Callable = lambda x: "0000" if x == "navigation" else x or "", order_key: Callable = lambda x: getattr(x, "order", "") or 0, ) -> Iterator[list[MenuOrSubmenu]]: """Sort a list of menu items based on their .group and .order attributes.""" groups: dict[str | None, list[MenuOrSubmenu]] = {} for item in items: groups.setdefault(item.group, []).append(item) for group_id in sorted(groups, key=group_key): yield sorted(groups[group_id], key=order_key) app-model-0.3.0/src/app_model/registries/_register.py000066400000000000000000000233751467241150700226550ustar00rootroot00000000000000from __future__ import annotations from typing import TYPE_CHECKING, overload from app_model.types import Action if TYPE_CHECKING: from typing import Any, Callable, Literal, TypeVar from app_model import Application, expressions from app_model.types import ( DisposeCallable, Icon, # noqa: F401 ... used in type hints for docs IconOrDict, KeyBindingRuleOrDict, MenuRuleOrDict, ) CommandCallable = TypeVar("CommandCallable", bound=Callable[..., Any]) CommandDecorator = Callable[[Callable], Callable] @overload def register_action( app: Application | str, id_or_action: Action ) -> DisposeCallable: ... @overload def register_action( app: Application | str, id_or_action: str, title: str, *, callback: Literal[None] = ..., category: str | None = ..., tooltip: str | None = ..., icon: IconOrDict | None = ..., enablement: expressions.Expr | None = ..., menus: list[MenuRuleOrDict] | None = ..., keybindings: list[KeyBindingRuleOrDict] | None = ..., palette: bool = True, ) -> CommandDecorator: ... @overload def register_action( app: Application | str, id_or_action: str, title: str, *, callback: CommandCallable, category: str | None = ..., tooltip: str | None = ..., icon: IconOrDict | None = ..., enablement: expressions.Expr | None = ..., menus: list[MenuRuleOrDict] | None = ..., keybindings: list[KeyBindingRuleOrDict] | None = ..., palette: bool = True, ) -> DisposeCallable: ... def register_action( app: Application | str, id_or_action: str | Action, title: str | None = None, *, callback: CommandCallable | None = None, category: str | None = None, tooltip: str | None = None, icon: IconOrDict | None = None, enablement: expressions.Expr | None = None, menus: list[MenuRuleOrDict] | None = None, keybindings: list[KeyBindingRuleOrDict] | None = None, palette: bool = True, ) -> CommandDecorator | DisposeCallable: """Register an action. This is a functional form of the [`Application.register_action()`][app_model.Application.register_action] method. It accepts various overloads to allow for a more concise syntax. See examples below. An `Action` is the "complete" representation of a command. The command is the function/callback itself, and an action also includes information about where and whether it appears in menus and optional keybinding rules. Since, most of the arguments to this function are simply passed through to the `Action` constructor, see also docstrings for: - [`Action`][app_model.types.Action] - [`CommandRule`][app_model.types.CommandRule] - [`MenuRule`][app_model.types.MenuRule] - [`KeyBindingRule`][app_model.types.KeyBindingRule] Parameters ---------- app: Application | str The app in which to register the action. If a string, the app is retrieved or created as necessary using [`Application.get_or_create(app)`][app_model.Application.get_or_create]. id_or_action : str | Action Either a complete Action object or a string id of the command being registered. If an `Action` object is provided, then all other arguments are ignored. title : str | None Title by which the command is represented in the UI. Required when `id_or_action` is a string. callback : CommandHandler | None Callable object that executes this command, by default None. If not provided, a decorator is returned that can be used to decorate a function that executes this action. category : str | None Category string by which the command may be grouped in the UI, by default None tooltip : str | None Tooltip to show when hovered., by default None icon : Icon | None [`Icon`][app_model.types.Icon] used to represent this command, e.g. on buttons or in menus. by default None enablement : expressions.Expr | None Condition which must be true to enable the command in in the UI, by default None menus : list[MenuRuleOrDict] | None List of [`MenuRule`][app_model.types.MenuRule] or kwarg `dicts` containing menu placements for this action, by default None keybindings : list[KeyBindingRuleOrDict] | None List of [`KeyBindingRule`][app_model.types.KeyBindingRule] or kwargs `dicts` containing default keybindings for this action, by default None palette : bool Whether to adds this command to the Command Palette, by default True Returns ------- CommandDecorator If `callback` is not provided, then a decorator is returned that can be used to decorate a function as the executor of the command. DisposeCallable If `callback` is provided, or `id_or_action` is an `Action` object, then a function is returned that may be used to unregister the action. Raises ------ ValueError If `id_or_action` is a string and `title` is not provided. TypeError If `id_or_action` is not a string or an `Action` object. Examples -------- This function can be used directly or as a decorator, and accepts arguments in various forms. ## Passing an existing Action object When the `id_or_action` argument is an instance of `app_model.Action`, then all other arguments are ignored, the action object is registered directly, and the return value is a function that may be used to unregister the action is returned. ```python from app_model import Application, Action, register_action app = Application.get_or_create("myapp") action = Action("my_action", title="My Action", callback=lambda: print("hi")) register_action(app, action) app.commands.execute_command("my_action") # prints "hi" ``` ## Creating a new Action When the `id_or_action` argument is a string, it is interpreted as the `id` of the command being registered, in which case `title` must then also be provided. All other arguments are optional, but may be used to customize the action being created (with keybindings, menus, icons, etc). ```python register_action( app, "my_action2", title="My Action2", callback=lambda: print("hello again!"), ) app.commands.execute_command("my_action2") # prints "hello again!" ``` ## Usage as a decorator If `callback` is not provided, then a decorator is returned that can be used decorate a function as the executor of the command: ```python @register_action(app, "my_action3", title="My Action3") def my_action3(): print("hello again, again!") app.commands.execute_command("my_action3") # prints "hello again, again!" ``` ## Passing app as a string Note that in all of the above examples, the first `app` argument may be either an instance of an [`Application`][app_model.Application] object, or a string name of an application. If a string is provided, then the application is retrieved or created as necessary using [`Application.get_or_create()`][app_model.Application.get_or_create]. ```python register_action( "myapp", # app name instead of Application instance "my_action4", title="My Action4", callback=lambda: print("hello again, again, again!"), ) ``` """ if isinstance(id_or_action, Action): return _register_action_obj(app, id_or_action) if isinstance(id_or_action, str): if not title: raise ValueError("'title' is required when 'id' is a string") return _register_action_str( app=app, id=id_or_action, title=title, category=category, tooltip=tooltip, icon=icon, enablement=enablement, callback=callback, palette=palette, menus=menus, keybindings=keybindings, ) raise TypeError("'id_or_action' must be a string or an Action") def _register_action_str( app: Application | str, **kwargs: Any ) -> CommandDecorator | DisposeCallable: """Create and register an Action with a string id and title. Helper for `register_action()`. If `kwargs['callback']` is a callable, a complete `Action` is created (thereby performing type validation and casting) and registered with the corresponding registries. Otherwise a decorator returned that can be used to decorate the callable that executes the action. """ if kwargs.get("callback") is not None: return _register_action_obj(app, Action(**kwargs)) def decorator(command: CommandCallable, **k: Any) -> CommandCallable: if not callable(command): raise TypeError( "@register_action decorator must be passed a callable object" ) _register_action_obj(app, Action(**{**kwargs, **k, "callback": command})) return command decorator.__doc__ = f"Decorate function as callback for command {kwargs['id']!r}" return decorator def _register_action_obj(app: Application | str, action: Action) -> DisposeCallable: """Register an Action object. Return a function that unregisters the action. Helper for `register_action()`. """ from app_model._app import Application app = app if isinstance(app, Application) else Application.get_or_create(app) # commands disposers = [app.commands.register_action(action)] # menus if dm := app.menus.append_action_menus(action): disposers.append(dm) # keybindings if dk := app.keybindings.register_action_keybindings(action): disposers.append(dk) def _dispose() -> None: for d in disposers: d() app._disposers.append((action.id, _dispose)) return _dispose app-model-0.3.0/src/app_model/types/000077500000000000000000000000001467241150700172725ustar00rootroot00000000000000app-model-0.3.0/src/app_model/types/__init__.py000066400000000000000000000025261467241150700214100ustar00rootroot00000000000000"""App-model types.""" from typing import TYPE_CHECKING from ._action import Action from ._command_rule import CommandRule, ToggleRule from ._constants import OperatingSystem from ._icon import Icon from ._keybinding_rule import KeyBindingRule from ._keys import ( KeyBinding, KeyChord, KeyCode, KeyCombo, KeyMod, SimpleKeyBinding, StandardKeyBinding, ) from ._menu_rule import MenuItem, MenuItemBase, MenuRule, SubmenuItem if TYPE_CHECKING: from typing import Callable, TypeAlias from ._icon import IconOrDict as IconOrDict from ._keybinding_rule import KeyBindingRuleDict as KeyBindingRuleDict from ._keybinding_rule import KeyBindingRuleOrDict as KeyBindingRuleOrDict from ._menu_rule import MenuOrSubmenu as MenuOrSubmenu from ._menu_rule import MenuRuleDict as MenuRuleDict from ._menu_rule import MenuRuleOrDict as MenuRuleOrDict # function that can be called without arguments to dispose of a resource DisposeCallable: TypeAlias = Callable[[], None] __all__ = [ "Action", "CommandRule", "Icon", "KeyBinding", "KeyBindingRule", "KeyChord", "KeyCode", "KeyCombo", "KeyMod", "OperatingSystem", "MenuItem", "MenuItemBase", "MenuRule", "ScanCode", "SimpleKeyBinding", "StandardKeyBinding", "SubmenuItem", "ToggleRule", ] app-model-0.3.0/src/app_model/types/_action.py000066400000000000000000000046611467241150700212670ustar00rootroot00000000000000from typing import TYPE_CHECKING, Callable, Generic, List, Optional, TypeVar, Union from pydantic_compat import Field, field_validator from ._command_rule import CommandRule from ._keybinding_rule import KeyBindingRule from ._menu_rule import MenuRule from ._utils import _validate_python_name # maintain runtime compatibility with older typing_extensions if TYPE_CHECKING: from typing_extensions import ParamSpec P = ParamSpec("P") else: try: from typing_extensions import ParamSpec P = ParamSpec("P") except ImportError: P = TypeVar("P") R = TypeVar("R") class Action(CommandRule, Generic[P, R]): """An Action is a callable object with menu placement, keybindings, and metadata. This is the "complete" representation of a command. Including a pointer to the actual callable object, as well as any additional menu and keybinding rules. Most commands and menu items will be represented by Actions, and registered using `register_action`. """ callback: Union[Callable[P, R], str] = Field( ..., description="A function to call when the associated command id is executed. " "If a string is provided, it must be a fully qualified name to a callable " "python object. This usually takes the form of " "`{obj.__module__}:{obj.__qualname__}` " "(e.g. `my_package.a_module:some_function`)", ) menus: Optional[List[MenuRule]] = Field( None, description="(Optional) Menus to which this action should be added. Note that " "menu items in the sequence may be supplied as a plain string, which will " "be converted to a `MenuRule` with the string as the `id` field.", ) keybindings: Optional[List[KeyBindingRule]] = Field( None, description="(Optional) Default keybinding(s) that will trigger this command.", ) palette: bool = Field( True, description="Whether to add this command to the global Command Palette " "during registration.", ) @field_validator("callback") def _validate_callback(callback: object) -> Union[Callable, str]: """Assert that `callback` is a callable or valid fully qualified name.""" if callable(callback): return callback elif isinstance(callback, str): return _validate_python_name(str(callback)) raise TypeError("callback must be a callable or a string") # pragma: no cover app-model-0.3.0/src/app_model/types/_base.py000066400000000000000000000006321467241150700207160ustar00rootroot00000000000000from typing import TYPE_CHECKING, ClassVar from pydantic_compat import BaseModel if TYPE_CHECKING: from pydantic import ConfigDict class _BaseModel(BaseModel): """Base model for all types.""" # don't switch to exclude ... it makes it hard to add fields to the # schema without breaking backwards compatibility model_config: ClassVar["ConfigDict"] = {"frozen": True, "extra": "ignore"} app-model-0.3.0/src/app_model/types/_command_rule.py000066400000000000000000000071321467241150700224530ustar00rootroot00000000000000from typing import Callable, Optional, Union from pydantic_compat import Field from app_model import expressions from ._base import _BaseModel from ._icon import Icon class ToggleRule(_BaseModel): """More detailed description of a toggle rule.""" condition: Optional[expressions.Expr] = Field( None, description="(Optional) Condition under which the command should appear " "checked/toggled in any GUI representation (like a menu or button).", ) get_current: Optional[Callable[[], bool]] = Field( None, description="Function that returns the current state of the toggle.", ) class CommandRule(_BaseModel): """Data representing a command and its presentation. Presentation of contributed commands depends on the containing menu. The Command Palette, for instance, prefixes commands with their category, allowing for easy grouping. However, the Command Palette doesn't show icons nor disabled commands. Menus, on the other hand, shows disabled items as grayed out, but don't show the category label. """ id: str = Field(..., description="A global identifier for the command.") title: str = Field( ..., description="Title by which the command is represented in the UI.", ) category: Optional[str] = Field( None, description="(Optional) Category string by which the command may be grouped " "in the UI", ) tooltip: Optional[str] = Field( None, description="(Optional) Tooltip to show when hovered." ) status_tip: Optional[str] = Field( None, description="(Optional) Help message to show in the status bar when a " "button representing this command is hovered (for backends that support it).", ) icon: Optional[Icon] = Field( None, description="(Optional) Icon used to represent this command, e.g. on buttons " "or in menus. These may be [iconify keys](https://icon-sets.iconify.design), " "such as `fa6-solid:arrow-down`, or " "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`, or a path to a local `.svg` file using the " "[file URI scheme](https://en.wikipedia.org/wiki/File_URI_scheme). " "Note that on Windows the file URI scheme should always start with " "`file:///` (three slashes)", ) icon_visible_in_menu: bool = Field( True, description="Whether to show the icon in menus (for backends that support it). " "If `False`, only the title will be shown. By default, `True`.", ) enablement: Optional[expressions.Expr] = Field( None, description="(Optional) Condition which must be true to enable the command in " "the UI (menu and keybindings). Does not prevent executing the command by " "other means, like the `execute_command` API.", ) short_title: Optional[str] = Field( None, description="(Optional) Short title by which the command is represented in " "the UI. Menus pick either `title` or `short_title` depending on the context " "in which they show commands.", ) toggled: Union[ToggleRule, expressions.Expr, None] = Field( None, description="(Optional) Condition under which the command should appear " "checked/toggled in any GUI representation (like a menu or button).", ) def _as_command_rule(self) -> "CommandRule": """Simplify (subclasses) to a plain CommandRule.""" return CommandRule(**{f: getattr(self, f) for f in CommandRule.__annotations__}) app-model-0.3.0/src/app_model/types/_constants.py000066400000000000000000000020271467241150700220200ustar00rootroot00000000000000import os import sys from enum import Enum class OperatingSystem(Enum): """Operating system enum.""" UNKNOWN = 0 WINDOWS = 1 MACOS = 2 LINUX = 3 @staticmethod def current() -> "OperatingSystem": """Return the current operating system as enum.""" return _CURRENT @property def is_windows(self) -> bool: """Returns True if the current operating system is Windows.""" return _CURRENT == OperatingSystem.WINDOWS @property def is_linux(self) -> bool: """Returns True if the current operating system is Linux.""" return _CURRENT == OperatingSystem.LINUX @property def is_mac(self) -> bool: """Returns True if the current operating system is MacOS.""" return _CURRENT == OperatingSystem.MACOS _CURRENT = OperatingSystem.UNKNOWN if os.name == "nt": _CURRENT = OperatingSystem.WINDOWS if sys.platform.startswith("linux"): _CURRENT = OperatingSystem.LINUX elif sys.platform == "darwin": _CURRENT = OperatingSystem.MACOS app-model-0.3.0/src/app_model/types/_icon.py000066400000000000000000000034761467241150700207450ustar00rootroot00000000000000from typing import Any, Callable, Generator, Optional, TypedDict, Union from pydantic_compat import Field, model_validator from ._base import _BaseModel class Icon(_BaseModel): """Icons used to represent commands, or submenus. May provide both a light and dark variant. If only one is provided, it is used in all theme types. """ dark: Optional[str] = Field( None, description="Icon path when a dark theme is used. These may be " "[iconify keys](https://icon-sets.iconify.design), such as " "`fa6-solid:arrow-down`, or " "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`", ) light: Optional[str] = Field( None, description="Icon path when a light theme is used. These may be " "[iconify keys](https://icon-sets.iconify.design), such as " "`fa6-solid:arrow-down`, or " "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`", ) @classmethod def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: yield cls._validate @classmethod def _validate(cls, v: Any) -> "Icon": """Validate icon.""" # if a single string is passed, use it for both light and dark. if isinstance(v, Icon): return v if isinstance(v, str): v = {"dark": v, "light": v} return cls(**v) # for v2 @model_validator(mode="before") @classmethod def _model_val(cls, v: dict) -> dict: if isinstance(v, str): v = {"dark": v, "light": v} return v class IconDict(TypedDict): """Icon dictionary.""" dark: Optional[str] light: Optional[str] IconOrDict = Union[Icon, IconDict] app-model-0.3.0/src/app_model/types/_keybinding_rule.py000066400000000000000000000061671467241150700231670ustar00rootroot00000000000000from typing import Any, Callable, Optional, Type, TypedDict, TypeVar, Union from pydantic_compat import PYDANTIC2, Field, model_validator from app_model import expressions from ._base import _BaseModel from ._constants import OperatingSystem from ._keys import StandardKeyBinding KeyEncoding = Union[int, str] M = TypeVar("M") _OS = OperatingSystem.current() _WIN = _OS.is_windows _MAC = _OS.is_mac _LINUX = _OS.is_linux class KeyBindingRule(_BaseModel): """Data representing a keybinding and when it should be active. This model lacks a corresponding command. That gets linked up elsewhere, such as below in `Action`. Values can be expressed as either a string (e.g. `"Ctrl+O"`) or an integer, using combinations of [`KeyMod`][app_model.types.KeyMod] and [`KeyCode`][app_model.types.KeyCode], (e.g. `KeyMod.CtrlCmd | KeyCode.KeyO`). """ primary: Optional[KeyEncoding] = Field( None, description="(Optional) Key combo, (e.g. Ctrl+O)." ) win: Optional[KeyEncoding] = Field( None, description="(Optional) Windows specific key combo." ) mac: Optional[KeyEncoding] = Field( None, description="(Optional) MacOS specific key combo." ) linux: Optional[KeyEncoding] = Field( None, description="(Optional) Linux specific key combo." ) when: Optional[expressions.Expr] = Field( None, description="(Optional) Condition when the keybingding is active.", ) weight: int = Field( 0, description="Internal weight used to sort keybindings. " "This is not part of the plugin schema", ) def _bind_to_current_platform(self) -> Optional[KeyEncoding]: if _WIN and self.win: return self.win if _MAC and self.mac: return self.mac if _LINUX and self.linux: return self.linux return self.primary # These methods are here to make KeyBindingRule work as a field # there are better ways to do this now with pydantic v2... but it still # feels a bit in flux. pydantic_compat might not yet work for this (or # at least in my playing around i couldn't get it) # so sticking with this one conditional method here... if PYDANTIC2: # for v2 @model_validator(mode="wrap") @classmethod def _model_val( cls: Type[M], v: Any, handler: Callable[[Any], M] ) -> "KeyBindingRule": if isinstance(v, StandardKeyBinding): return v.to_keybinding_rule() return handler(v) # type: ignore else: @classmethod def validate(cls, value: Any) -> "KeyBindingRule": """Validate keybinding rule.""" if isinstance(value, StandardKeyBinding): return value.to_keybinding_rule() return super().validate(value) class KeyBindingRuleDict(TypedDict, total=False): """Typed dict for KeyBindingRule kwargs.""" primary: Optional[str] win: Optional[str] linux: Optional[str] mac: Optional[str] weight: int when: Optional[expressions.Expr] KeyBindingRuleOrDict = Union[KeyBindingRule, KeyBindingRuleDict] app-model-0.3.0/src/app_model/types/_keys/000077500000000000000000000000001467241150700204045ustar00rootroot00000000000000app-model-0.3.0/src/app_model/types/_keys/__init__.py000066400000000000000000000005201467241150700225120ustar00rootroot00000000000000from ._key_codes import KeyChord, KeyCode, KeyCombo, KeyMod, ScanCode from ._keybindings import KeyBinding, SimpleKeyBinding from ._standard_bindings import StandardKeyBinding __all__ = [ "KeyBinding", "KeyChord", "KeyCode", "KeyCombo", "KeyMod", "ScanCode", "StandardKeyBinding", "SimpleKeyBinding", ] app-model-0.3.0/src/app_model/types/_keys/_key_codes.py000066400000000000000000001125551467241150700230730ustar00rootroot00000000000000from enum import IntEnum, IntFlag, auto from typing import ( TYPE_CHECKING, Any, Callable, Dict, Generator, NamedTuple, Optional, Set, Tuple, Type, Union, overload, ) from app_model.types._constants import OperatingSystem if TYPE_CHECKING: from pydantic.annotated_handlers import GetCoreSchemaHandler from pydantic_core import core_schema __all__ = ["KeyCode", "KeyMod", "ScanCode", "KeyChord"] # TODO: # https://stackoverflow.com/questions/3202629/where-can-i-find-a-list-of-mac-virtual-key-codes/16125341#16125341 # flake8: noqa # fmt: off class KeyCode(IntEnum): """Virtual Key Codes, the integer value does not hold any inherent meaning. This is the primary internal representation of a key. """ UNKNOWN = 0 # ----------------------- Writing System Keys ----------------------- Backquote = auto() # `~ on a US keyboard. Backslash = auto() # \| on a US keyboard. BracketLeft = auto() # [{ on a US keyboard. BracketRight = auto() # ]} on a US keyboard. Comma = auto() # ,< on a US keyboard. Digit0 = auto() # 0) on a US keyboard. Digit1 = auto() # 1! on a US keyboard. Digit2 = auto() # 2@ on a US keyboard. Digit3 = auto() # 3# on a US keyboard. Digit4 = auto() # 4$ on a US keyboard. Digit5 = auto() # 5% on a US keyboard. Digit6 = auto() # 6^ on a US keyboard. Digit7 = auto() # 7& on a US keyboard. Digit8 = auto() # 8* on a US keyboard. Digit9 = auto() # 9( on a US keyboard. Equal = auto() # =+ on a US keyboard. IntlBackslash = auto() # Located between the left Shift and Z keys. Labelled \| on a UK keyboard. KeyA = auto() KeyB = auto() KeyC = auto() KeyD = auto() KeyE = auto() KeyF = auto() KeyG = auto() KeyH = auto() KeyI = auto() KeyJ = auto() KeyK = auto() KeyL = auto() KeyM = auto() KeyN = auto() KeyO = auto() KeyP = auto() KeyQ = auto() KeyR = auto() KeyS = auto() KeyT = auto() KeyU = auto() KeyV = auto() KeyW = auto() KeyX = auto() KeyY = auto() KeyZ = auto() Minus = auto() # -_ on a US keyboard. Period = auto() # .> on a US keyboard. Quote = auto() # '" on a US keyboard. Semicolon = auto() # ;: on a US keyboard. Slash = auto() # /? on a US keyboard. # ------------------- Functional Keys -------------------------------- Alt = auto() # Alt, Option or ⌥. Backspace = auto() # Backspace or ⌫. Labelled Delete on Apple keyboards. CapsLock = auto() # CapsLock or ⇪ ContextMenu = auto() # The application context menu key, which is typically found between the right Meta key and the right Control key. Ctrl = auto() # Control or ⌃ Enter = auto() # Enter or ↵. Labelled Return on Apple keyboards. Meta = auto() # The Windows, ⌘, Command or other OS symbol key. Shift = auto() # Shift or ⇧ Space = auto() # (space) Tab = auto() # Tab or ⇥ # ---------------------- Control Pad -------------------------------- Delete = auto() # ⌦. The forward delete key. NOT the Delete key on a mac End = auto() # Page Down, End or ↘ Home = auto() # Home or ↖ Insert = auto() # Insert or Ins. Not present on Apple keyboards. PageDown = auto() # Page Down, PgDn or ⇟ PageUp = auto() # Page Up, PgUp or ⇞ # ----------------------- Arrow Pad ---------------------------------- DownArrow = auto() # ↓ LeftArrow = auto() # ↠RightArrow = auto() # → UpArrow = auto() # ↑ # ----------------------- Numpad Section ----------------------------- NumLock = auto() # Numpad0 = auto() # 0 Numpad1 = auto() # 1 Numpad2 = auto() # 2 Numpad3 = auto() # 3 Numpad4 = auto() # 4 Numpad5 = auto() # 5 Numpad6 = auto() # 6 Numpad7 = auto() # 7 Numpad8 = auto() # 8 Numpad9 = auto() # 9 NumpadAdd = auto() # + NumpadDecimal = auto() # . NumpadDivide = auto() # / NumpadMultiply = auto() # * NumpadSubtract = auto() # - # --------------------- Function Section ----------------------------- Escape = auto() # Esc or ⎋ F1 = auto() F2 = auto() F3 = auto() F4 = auto() F5 = auto() F6 = auto() F7 = auto() F8 = auto() F9 = auto() F10 = auto() F11 = auto() F12 = auto() PrintScreen = auto() ScrollLock = auto() PauseBreak = auto() def __str__(self) -> str: """Get a normalized string representation (constant to all OSes) of this `KeyCode`.""" return keycode_to_string(self) def os_symbol(self, os: Optional[OperatingSystem] = None) -> str: """Get a string representation of this `KeyCode` using a symbol/OS specific symbol. Some examples: * `KeyCode.Enter` is represented by `↵` * `KeyCode.Meta` is represented by `⊞` on Windows, `Super` on Linux and `⌘` on MacOS If no OS is given, the current detected one is used. """ os = OperatingSystem.current() if os is None else os return keycode_to_os_symbol(self, os) def os_name(self, os: Optional[OperatingSystem] = None) -> str: """Get a string representation of this `KeyCode` using the OS specific naming for the key. This differs from `__str__` since with it a normalized representation (constant to all OSes) is given. Sometimes these representations coincide but not always! Some examples: * `KeyCode.Enter` is represented by `Enter` (`__str__` represents it as `Enter`) * `KeyCode.Meta` is represented by `Win` on Windows, `Super` on Linux and `Cmd` on MacOS (`__str__` represents it as `Meta`) If no OS is given, the current detected one is used. """ os = OperatingSystem.current() if os is None else os return keycode_to_os_name(self, os) @classmethod def from_string(cls, string: str) -> 'KeyCode': """Return the `KeyCode` associated with the given string. Returns `KeyCode.UNKNOWN` if no `KeyCode` is associated with the string. """ return keycode_from_string(string) @classmethod def from_event_code(cls, event_code: int) -> 'KeyCode': """Return the `KeyCode` associated with the given event code. Returns `KeyCode.UNKNOWN` if no `KeyCode` is associated with the event code. """ return _EVENTCODE_TO_KEYCODE.get(event_code, KeyCode.UNKNOWN) @classmethod def __get_validators__(cls) -> Generator[Callable[..., 'KeyCode'], None, None]: yield cls.validate @classmethod def __get_pydantic_core_schema__( cls, source: type, handler: 'GetCoreSchemaHandler' ) -> 'core_schema.CoreSchema': from pydantic_core import core_schema return core_schema.no_info_plain_validator_function(cls.validate) @classmethod def validate(cls, value: Any) -> 'KeyCode': if isinstance(value, KeyCode): return value if isinstance(value, int): return cls(value) if isinstance(value, str): return cls.from_string(value) raise TypeError(f'cannot convert type {type(value)!r} to KeyCode') class ScanCode(IntEnum): """Scan codes for the keyboard. https://en.wikipedia.org/wiki/Scancode These are the scan codes required to conform to the W3C specification for KeyboardEvent.code A scan code is a hardware-specific code that is generated by the keyboard when a key is pressed or released. It represents the physical location of a key on the keyboard and is unique to each key. A key code, on the other hand, is a higher-level representation of a keypress or key release event. They are associated with characters, functions, or actions rather than hardware locations. As an example, the left and right control keys have the same key code (KeyCode.Ctrl) but different scan codes (LeftControl and RightControl). https://w3c.github.io/uievents-code/ commented out lines represent keys that are optional and may be used by implementations to support special keyboards (such as multimedia or legacy keyboards). """ UNIDENTIFIED = 0 # This value code should be used when no other value given in this specification is appropriate. # ----------------------- Writing System Keys ----------------------- # https://w3c.github.io/uievents-code/#key-alphanumeric-writing-system # The writing system keys are those that change meaning (i.e., they produce # different key values) based on the current locale and keyboard layout. # ---------------------------------------------------------------------- Backquote = auto() # `~ on a US keyboard. This is the åŠè§’/全角/漢字 (hankaku/zenkaku/kanji) key on Japanese keyboards Backslash = auto() # Used for both the US \| (on the 101-key layout) and also for the key located between the " and Enter keys on row C of the 102-, 104- and 106-key layouts. Labelled #~ on a UK (102) keyboard. BracketLeft = auto() # [{ on a US keyboard. BracketRight = auto() # ]} on a US keyboard. Comma = auto() # ,< on a US keyboard. Digit0 = auto() # 0) on a US keyboard. Digit1 = auto() # 1! on a US keyboard. Digit2 = auto() # 2@ on a US keyboard. Digit3 = auto() # 3# on a US keyboard. Digit4 = auto() # 4$ on a US keyboard. Digit5 = auto() # 5% on a US keyboard. Digit6 = auto() # 6^ on a US keyboard. Digit7 = auto() # 7& on a US keyboard. Digit8 = auto() # 8* on a US keyboard. Digit9 = auto() # 9( on a US keyboard. Equal = auto() # =+ on a US keyboard. IntlBackslash = auto() # Located between the left Shift and Z keys. Labelled \| on a UK keyboard. IntlRo = auto() # Located between the / and right Shift keys. Labelled \ã‚ (ro) on a Japanese keyboard. IntlYen = auto() # Located between the = and Backspace keys. Labelled Â¥ (yen) on a Japanese keyboard. \/ on a Russian keyboard. KeyA = auto() # a on a US keyboard. Labelled q on an AZERTY (e.g., French) keyboard. KeyB = auto() # b on a US keyboard. KeyC = auto() # c on a US keyboard. KeyD = auto() # d on a US keyboard. KeyE = auto() # e on a US keyboard. KeyF = auto() # f on a US keyboard. KeyG = auto() # g on a US keyboard. KeyH = auto() # h on a US keyboard. KeyI = auto() # i on a US keyboard. KeyJ = auto() # j on a US keyboard. KeyK = auto() # k on a US keyboard. KeyL = auto() # l on a US keyboard. KeyM = auto() # m on a US keyboard. KeyN = auto() # n on a US keyboard. KeyO = auto() # o on a US keyboard. KeyP = auto() # p on a US keyboard. KeyQ = auto() # q on a US keyboard. Labelled a on an AZERTY (e.g., French) keyboard. KeyR = auto() # r on a US keyboard. KeyS = auto() # s on a US keyboard. KeyT = auto() # t on a US keyboard. KeyU = auto() # u on a US keyboard. KeyV = auto() # v on a US keyboard. KeyW = auto() # w on a US keyboard. Labelled z on an AZERTY (e.g., French) keyboard. KeyX = auto() # x on a US keyboard. KeyY = auto() # y on a US keyboard. Labelled z on a QWERTZ (e.g., German) keyboard. KeyZ = auto() # z on a US keyboard. Labelled w on an AZERTY (e.g., French) keyboard, and y on a QWERTZ (e.g., German) keyboard. Minus = auto() # -_ on a US keyboard. Period = auto() # .> on a US keyboard. Quote = auto() # '" on a US keyboard. Semicolon = auto() # ;: on a US keyboard. Slash = auto() # /? on a US keyboard. # ------------------- Functional Keys -------------------------------- # https://w3c.github.io/uievents-code/#key-alphanumeric-functional # The functional keys (not to be confused with the function keys described later) # are those keys in the alphanumeric section that provide general editing functions # that are common to all locales (like Shift, Tab, Enter and Backspace). # With a few exceptions, these keys do not change meaning based on the current # keyboard layout. # ------------------------------------------------------------------------ AltLeft = auto() # Alt, Option or ⌥. AltRight = auto() # Alt, Option or ⌥. This is labelled AltGr key on many keyboard layouts. Backspace = auto() # Backspace or ⌫. Labelled Delete on Apple keyboards. CapsLock = auto() # CapsLock or ⇪ ContextMenu = auto() # The application context menu key, which is typically found between the right Meta key and the right Control key. ControlLeft = auto() # Control or ⌃ ControlRight = auto() # Control or ⌃ Enter = auto() # Enter or ↵. Labelled Return on Apple keyboards. MetaLeft = auto() # The Windows, ⌘, Command or other OS symbol key. MetaRight = auto() # The Windows, ⌘, Command or other OS symbol key. ShiftLeft = auto() # Shift or ⇧ ShiftRight = auto() # Shift or ⇧ Space = auto() # (space) Tab = auto() # Tab or ⇥ # Japanese and Korean keyboards. Convert = auto() # Japanese: å¤‰æ› (henkan) KanaMode = auto() # Japanese: カタカナ/ã²ã‚‰ãŒãª/ローマ字 (katakana/hiragana/romaji) NonConvert = auto() # Japanese: ç„¡å¤‰æ› (muhenkan) # Lang1 = auto() # Korean: HangulMode 한/ì˜ (han/yeong) Japanese (Mac keyboard): ã‹ãª (kana) # Lang2 = auto() # Korean: Hanja í•œìž (hanja) Japanese (Mac keyboard): 英数 (eisu) # Lang3 = auto() # Japanese (word-processing keyboard): Katakana # Lang4 = auto() # Japanese (word-processing keyboard): Hiragana # Lang5 = auto() # Japanese (word-processing keyboard): Zenkaku/Hankaku # ---------------------- Control Pad -------------------------------- # https://w3c.github.io/uievents-code/#key-controlpad-section # The control pad section of the keyboard is the set of (usually 6) keys that # perform navigating and editing operations, for example, Home, PageUp and Insert. # ------------------------------------------------------------------------ Delete = auto() # ⌦. The forward delete key. Note that on Apple keyboards, the key labelled Delete on the main part of the keyboard should be encoded as "Backspace". End = auto() # Page Down, End or ↘ Help = auto() # Help. Not present on standard PC keyboards. Home = auto() # Home or ↖ Insert = auto() # Insert or Ins. Not present on Apple keyboards. PageDown = auto() # Page Down, PgDn or ⇟ PageUp = auto() # Page Up, PgUp or ⇞ # ----------------------- Arrow Pad ---------------------------------- # https://w3c.github.io/uievents-code/#key-arrowpad-section # The arrow pad contains the 4 arrow keys. The keys are commonly arranged in an # "upside-down T" configuration. # ------------------------------------------------------------------------ ArrowDown = auto() # ↓ ArrowLeft = auto() # ↠ArrowRight = auto() # → ArrowUp = auto() # ↑ # ----------------------- Numpad Section ----------------------------- # https://w3c.github.io/uievents-code/#key-numpad-section # The numpad section is the set of keys on the keyboard arranged in a grid like a # calculator or mobile phone. This section contains numeric and mathematical # operator keys. Laptop computers and compact keyboards will commonly omit # these keys to save space. # ------------------------------------------------------------------------ NumLock = auto() # On the Mac, the "NumLock" code should be used for the numpad Clear key. Numpad0 = auto() # 0 Ins on a keyboard 0 on a phone or remote control Numpad1 = auto() # 1 End on a keyboard 1 or 1 QZ on a phone or remote control Numpad2 = auto() # 2 ↓ on a keyboard 2 ABC on a phone or remote control Numpad3 = auto() # 3 PgDn on a keyboard 3 DEF on a phone or remote control Numpad4 = auto() # 4 ↠on a keyboard 4 GHI on a phone or remote control Numpad5 = auto() # 5 on a keyboard 5 JKL on a phone or remote control Numpad6 = auto() # 6 → on a keyboard 6 MNO on a phone or remote control Numpad7 = auto() # 7 Home on a keyboard 7 PQRS or 7 PRS on a phone or remote control Numpad8 = auto() # 8 ↑ on a keyboard 8 TUV on a phone or remote control Numpad9 = auto() # 9 PgUp on a keyboard 9 WXYZ or 9 WXY on a phone or remote control NumpadAdd = auto() # + NumpadDecimal = auto() # . Del. For locales where the decimal separator is "," (e.g., Brazil), this key may generate a ,. NumpadDivide = auto() # / NumpadEnter = auto() # NumpadMultiply = auto() # * on a keyboard. For use with numpads that provide mathematical operations (+, -, * and /). Use "NumpadStar" for the * key on phones and remote controls. NumpadSubtract = auto() # - NumpadEqual = auto() # = NOTE: not required to conform to spec. # NumpadBackspace = auto() # Found on the Microsoft Natural Keyboard. # NumpadClear = auto() # C or AC (All Clear). Also for use with numpads that have a Clear key that is separate from the NumLock key. On the Mac, the numpad Clear key should always be encoded as "NumLock". # NumpadClearEntry = auto() # CE (Clear Entry) # NumpadComma = auto() # , (thousands separator). For locales where the thousands separator is a "." (e.g., Brazil), this key may generate a .. # NumpadHash = auto() # # on a phone or remote control device. This key is typically found below the 9 key and to the right of the 0 key. # NumpadMemoryAdd = auto() # M+ Add current entry to the value stored in memory. # NumpadMemoryClear = auto() # MC Clear the value stored in memory. # NumpadMemoryRecall = auto() # MR Replace the current entry with the value stored in memory. # NumpadMemoryStore = auto() # MS Replace the value stored in memory with the current entry. # NumpadMemorySubtract = auto() # M- Subtract current entry from the value stored in memory. # NumpadParenLeft = auto() # ( Found on the Microsoft Natural Keyboard. # NumpadParenRight = auto() # ) Found on the Microsoft Natural Keyboard. # NumpadStar = auto() # * on a phone or remote control device. This key is typically found below the 7 key and to the left of the 0 key. Use "NumpadMultiply" for the * key on numeric keypads. # --------------------- Function Section ----------------------------- # https://w3c.github.io/uievents-code/#key-function-section # The function section runs along the top of the keyboard (above the alphanumeric # section) and contains the function keys and a few additional special keys # (for example, Esc and Print Screen). A function key is any of the keys labelled # F1 ... F12 that an application or operating system can associate with a # custom function or action. # ------------------------------------------------------------------------ Escape = auto() # Esc or ⎋ F1 = auto() # F1 F2 = auto() # F2 F3 = auto() # F3 F4 = auto() # F4 F5 = auto() # F5 F6 = auto() # F6 F7 = auto() # F7 F8 = auto() # F8 F9 = auto() # F9 F10 = auto() # F10 F11 = auto() # F11 F12 = auto() # F12 PrintScreen = auto() # PrtScr SysRq or Print Screen ScrollLock = auto() # Scroll Lock Pause = auto() # Pause Break # Fn = auto() # Fn This is typically a hardware key that does not generate a separate code. Most keyboards do not place this key in the function section, but it is included here to keep it with related keys. # FnLock = auto() # FLock or FnLock. Function Lock key. Found on the Microsoft Natural Keyboard. # --------------------- Media Keys ---------------------------- # https://w3c.github.io/uievents-code/#key-media # none of these are required to conform to the spec, and are omitted for now # ------------ Legacy, Non-Standard and Special Keys -------------- # https://w3c.github.io/uievents-code/#key-legacy # none of these are required to conform to the spec, and are omitted for now def __str__(self) -> str: return scancode_to_string(self) @classmethod def from_string(cls, string: str) -> 'ScanCode': """Return the KeyCode associated with the given string. Returns ScanCode.UNIDENTIFIED if no match is found. """ return scancode_from_string(string) _EVENTCODE_TO_KEYCODE: Dict[int, KeyCode] = {} _NATIVE_WINDOWS_VK_TO_KEYCODE: Dict[str, KeyCode] = {} # build in a closure to prevent modification and declutter namespace def _build_maps() -> Tuple[ Callable[[KeyCode], str], Callable[[str], KeyCode], Callable[[KeyCode, OperatingSystem], str], Callable[[KeyCode, OperatingSystem], str], Callable[[ScanCode], str], Callable[[str], ScanCode], ]: class _KM(NamedTuple): scancode: ScanCode scanstr: str keycode: KeyCode keystr: str eventcode: int virtual_key: str _ = '' _MAPPINGS = [ _KM(ScanCode.UNIDENTIFIED, 'None', KeyCode.UNKNOWN, 'unknown', 0, 'VK_UNKNOWN'), _KM(ScanCode.KeyA, 'KeyA', KeyCode.KeyA, 'A', 65, 'VK_A'), _KM(ScanCode.KeyB, 'KeyB', KeyCode.KeyB, 'B', 66, 'VK_B'), _KM(ScanCode.KeyC, 'KeyC', KeyCode.KeyC, 'C', 67, 'VK_C'), _KM(ScanCode.KeyD, 'KeyD', KeyCode.KeyD, 'D', 68, 'VK_D'), _KM(ScanCode.KeyE, 'KeyE', KeyCode.KeyE, 'E', 69, 'VK_E'), _KM(ScanCode.KeyF, 'KeyF', KeyCode.KeyF, 'F', 70, 'VK_F'), _KM(ScanCode.KeyG, 'KeyG', KeyCode.KeyG, 'G', 71, 'VK_G'), _KM(ScanCode.KeyH, 'KeyH', KeyCode.KeyH, 'H', 72, 'VK_H'), _KM(ScanCode.KeyI, 'KeyI', KeyCode.KeyI, 'I', 73, 'VK_I'), _KM(ScanCode.KeyJ, 'KeyJ', KeyCode.KeyJ, 'J', 74, 'VK_J'), _KM(ScanCode.KeyK, 'KeyK', KeyCode.KeyK, 'K', 75, 'VK_K'), _KM(ScanCode.KeyL, 'KeyL', KeyCode.KeyL, 'L', 76, 'VK_L'), _KM(ScanCode.KeyM, 'KeyM', KeyCode.KeyM, 'M', 77, 'VK_M'), _KM(ScanCode.KeyN, 'KeyN', KeyCode.KeyN, 'N', 78, 'VK_N'), _KM(ScanCode.KeyO, 'KeyO', KeyCode.KeyO, 'O', 79, 'VK_O'), _KM(ScanCode.KeyP, 'KeyP', KeyCode.KeyP, 'P', 80, 'VK_P'), _KM(ScanCode.KeyQ, 'KeyQ', KeyCode.KeyQ, 'Q', 81, 'VK_Q'), _KM(ScanCode.KeyR, 'KeyR', KeyCode.KeyR, 'R', 82, 'VK_R'), _KM(ScanCode.KeyS, 'KeyS', KeyCode.KeyS, 'S', 83, 'VK_S'), _KM(ScanCode.KeyT, 'KeyT', KeyCode.KeyT, 'T', 84, 'VK_T'), _KM(ScanCode.KeyU, 'KeyU', KeyCode.KeyU, 'U', 85, 'VK_U'), _KM(ScanCode.KeyV, 'KeyV', KeyCode.KeyV, 'V', 86, 'VK_V'), _KM(ScanCode.KeyW, 'KeyW', KeyCode.KeyW, 'W', 87, 'VK_W'), _KM(ScanCode.KeyX, 'KeyX', KeyCode.KeyX, 'X', 88, 'VK_X'), _KM(ScanCode.KeyY, 'KeyY', KeyCode.KeyY, 'Y', 89, 'VK_Y'), _KM(ScanCode.KeyZ, 'KeyZ', KeyCode.KeyZ, 'Z', 90, 'VK_Z'), _KM(ScanCode.Digit1, 'Digit1', KeyCode.Digit1, '1', 49, 'VK_1'), _KM(ScanCode.Digit2, 'Digit2', KeyCode.Digit2, '2', 50, 'VK_2'), _KM(ScanCode.Digit3, 'Digit3', KeyCode.Digit3, '3', 51, 'VK_3'), _KM(ScanCode.Digit4, 'Digit4', KeyCode.Digit4, '4', 52, 'VK_4'), _KM(ScanCode.Digit5, 'Digit5', KeyCode.Digit5, '5', 53, 'VK_5'), _KM(ScanCode.Digit6, 'Digit6', KeyCode.Digit6, '6', 54, 'VK_6'), _KM(ScanCode.Digit7, 'Digit7', KeyCode.Digit7, '7', 55, 'VK_7'), _KM(ScanCode.Digit8, 'Digit8', KeyCode.Digit8, '8', 56, 'VK_8'), _KM(ScanCode.Digit9, 'Digit9', KeyCode.Digit9, '9', 57, 'VK_9'), _KM(ScanCode.Digit0, 'Digit0', KeyCode.Digit0, '0', 48, 'VK_0'), _KM(ScanCode.Enter, 'Enter', KeyCode.Enter, 'Enter', 13, 'VK_RETURN'), _KM(ScanCode.Escape, 'Escape', KeyCode.Escape, 'Escape', 27, 'VK_ESCAPE'), _KM(ScanCode.Backspace, 'Backspace', KeyCode.Backspace, 'Backspace', 8, 'VK_BACK'), _KM(ScanCode.Tab, 'Tab', KeyCode.Tab, 'Tab', 9, 'VK_TAB'), _KM(ScanCode.Space, 'Space', KeyCode.Space, 'Space', 32, 'VK_SPACE'), _KM(ScanCode.Minus, 'Minus', KeyCode.Minus, '-', 189, 'VK_OEM_MINUS'), _KM(ScanCode.Equal, 'Equal', KeyCode.Equal, '=', 187, 'VK_OEM_PLUS'), _KM(ScanCode.BracketLeft, 'BracketLeft', KeyCode.BracketLeft, '[', 219, 'VK_OEM_4'), _KM(ScanCode.BracketRight, 'BracketRight', KeyCode.BracketRight, ']', 221, 'VK_OEM_6'), _KM(ScanCode.Backslash, 'Backslash', KeyCode.Backslash, '\\', 220, 'VK_OEM_5'), _KM(ScanCode.Semicolon, 'Semicolon', KeyCode.Semicolon, ';', 186, 'VK_OEM_1'), _KM(ScanCode.Quote, 'Quote', KeyCode.Quote, "'", 222, 'VK_OEM_7'), _KM(ScanCode.Backquote, 'Backquote', KeyCode.Backquote, '`', 192, 'VK_OEM_3'), _KM(ScanCode.Comma, 'Comma', KeyCode.Comma, ',', 188, 'VK_OEM_COMMA'), _KM(ScanCode.Period, 'Period', KeyCode.Period, '.', 190, 'VK_OEM_PERIOD'), _KM(ScanCode.Slash, 'Slash', KeyCode.Slash, '/', 191, 'VK_OEM_2'), _KM(ScanCode.CapsLock, 'CapsLock', KeyCode.CapsLock, 'CapsLock', 20, 'VK_CAPITAL'), _KM(ScanCode.F1, 'F1', KeyCode.F1, 'F1', 112, 'VK_F1'), _KM(ScanCode.F2, 'F2', KeyCode.F2, 'F2', 113, 'VK_F2'), _KM(ScanCode.F3, 'F3', KeyCode.F3, 'F3', 114, 'VK_F3'), _KM(ScanCode.F4, 'F4', KeyCode.F4, 'F4', 115, 'VK_F4'), _KM(ScanCode.F5, 'F5', KeyCode.F5, 'F5', 116, 'VK_F5'), _KM(ScanCode.F6, 'F6', KeyCode.F6, 'F6', 117, 'VK_F6'), _KM(ScanCode.F7, 'F7', KeyCode.F7, 'F7', 118, 'VK_F7'), _KM(ScanCode.F8, 'F8', KeyCode.F8, 'F8', 119, 'VK_F8'), _KM(ScanCode.F9, 'F9', KeyCode.F9, 'F9', 120, 'VK_F9'), _KM(ScanCode.F10, 'F10', KeyCode.F10, 'F10', 121, 'VK_F10'), _KM(ScanCode.F11, 'F11', KeyCode.F11, 'F11', 122, 'VK_F11'), _KM(ScanCode.F12, 'F12', KeyCode.F12, 'F12', 123, 'VK_F12'), _KM(ScanCode.PrintScreen, 'PrintScreen', KeyCode.PrintScreen, "PrintScreen", 42, "VK_PRINT"), _KM(ScanCode.ScrollLock, 'ScrollLock', KeyCode.ScrollLock, 'ScrollLock', 145, 'VK_SCROLL'), _KM(ScanCode.Pause, 'Pause', KeyCode.PauseBreak, 'PauseBreak', 19, 'VK_PAUSE'), _KM(ScanCode.Insert, 'Insert', KeyCode.Insert, 'Insert', 45, 'VK_INSERT'), _KM(ScanCode.Home, 'Home', KeyCode.Home, 'Home', 36, 'VK_HOME'), _KM(ScanCode.PageUp, 'PageUp', KeyCode.PageUp, 'PageUp', 33, 'VK_PRIOR'), _KM(ScanCode.Delete, 'Delete', KeyCode.Delete, 'Delete', 46, 'VK_DELETE'), _KM(ScanCode.End, 'End', KeyCode.End, 'End', 35, 'VK_END'), _KM(ScanCode.PageDown, 'PageDown', KeyCode.PageDown, 'PageDown', 34, 'VK_NEXT'), _KM(ScanCode.ArrowRight, 'ArrowRight', KeyCode.RightArrow, 'Right', 39, 'VK_RIGHT'), _KM(ScanCode.ArrowLeft, 'ArrowLeft', KeyCode.LeftArrow, 'Left', 37, 'VK_LEFT'), _KM(ScanCode.ArrowDown, 'ArrowDown', KeyCode.DownArrow, 'Down', 40, 'VK_DOWN'), _KM(ScanCode.ArrowUp, 'ArrowUp', KeyCode.UpArrow, 'Up', 38, 'VK_UP'), _KM(ScanCode.NumLock, 'NumLock', KeyCode.NumLock, 'NumLock', 144, 'VK_NUMLOCK'), _KM(ScanCode.NumpadDivide, 'NumpadDivide', KeyCode.NumpadDivide, 'NumPad_Divide', 111, 'VK_DIVIDE'), _KM(ScanCode.NumpadMultiply, 'NumpadMultiply', KeyCode.NumpadMultiply, 'NumPad_Multiply', 106, 'VK_MULTIPLY'), _KM(ScanCode.NumpadSubtract, 'NumpadSubtract', KeyCode.NumpadSubtract, 'NumPad_Subtract', 109, 'VK_SUBTRACT'), _KM(ScanCode.NumpadAdd, 'NumpadAdd', KeyCode.NumpadAdd, 'NumPad_Add', 107, 'VK_ADD'), _KM(ScanCode.NumpadEnter, 'NumpadEnter', KeyCode.Enter, _, 0, _), _KM(ScanCode.Numpad1, 'Numpad1', KeyCode.Numpad1, 'NumPad1', 97, 'VK_NUMPAD1'), _KM(ScanCode.Numpad2, 'Numpad2', KeyCode.Numpad2, 'NumPad2', 98, 'VK_NUMPAD2'), _KM(ScanCode.Numpad3, 'Numpad3', KeyCode.Numpad3, 'NumPad3', 99, 'VK_NUMPAD3'), _KM(ScanCode.Numpad4, 'Numpad4', KeyCode.Numpad4, 'NumPad4', 100, 'VK_NUMPAD4'), _KM(ScanCode.Numpad5, 'Numpad5', KeyCode.Numpad5, 'NumPad5', 101, 'VK_NUMPAD5'), _KM(ScanCode.Numpad6, 'Numpad6', KeyCode.Numpad6, 'NumPad6', 102, 'VK_NUMPAD6'), _KM(ScanCode.Numpad7, 'Numpad7', KeyCode.Numpad7, 'NumPad7', 103, 'VK_NUMPAD7'), _KM(ScanCode.Numpad8, 'Numpad8', KeyCode.Numpad8, 'NumPad8', 104, 'VK_NUMPAD8'), _KM(ScanCode.Numpad9, 'Numpad9', KeyCode.Numpad9, 'NumPad9', 105, 'VK_NUMPAD9'), _KM(ScanCode.Numpad0, 'Numpad0', KeyCode.Numpad0, 'NumPad0', 96, 'VK_NUMPAD0'), _KM(ScanCode.NumpadDecimal, 'NumpadDecimal', KeyCode.NumpadDecimal, 'NumPad_Decimal', 110, 'VK_DECIMAL'), _KM(ScanCode.IntlBackslash, 'IntlBackslash', KeyCode.IntlBackslash, 'OEM_102', 226, 'VK_OEM_102'), _KM(ScanCode.ContextMenu, 'ContextMenu', KeyCode.ContextMenu, 'ContextMenu', 93, _), _KM(ScanCode.NumpadEqual, 'NumpadEqual', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.Help, 'Help', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.IntlRo, 'IntlRo', KeyCode.UNKNOWN, _, 193, 'VK_ABNT_C1'), _KM(ScanCode.KanaMode, 'KanaMode', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.IntlYen, 'IntlYen', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.Convert, 'Convert', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.NonConvert, 'NonConvert', KeyCode.UNKNOWN, _, 0, _), _KM(ScanCode.UNIDENTIFIED, _, KeyCode.Ctrl, 'Ctrl', 17, 'VK_CONTROL'), _KM(ScanCode.UNIDENTIFIED, _, KeyCode.Shift, 'Shift', 16, 'VK_SHIFT'), _KM(ScanCode.UNIDENTIFIED, _, KeyCode.Alt, 'Alt', 18, 'VK_MENU'), _KM(ScanCode.UNIDENTIFIED, _, KeyCode.Meta, 'Meta', 0, 'VK_COMMAND'), _KM(ScanCode.ControlLeft, 'ControlLeft', KeyCode.Ctrl, _, 0, 'VK_LCONTROL'), _KM(ScanCode.ShiftLeft, 'ShiftLeft', KeyCode.Shift, _, 0, 'VK_LSHIFT'), _KM(ScanCode.AltLeft, 'AltLeft', KeyCode.Alt, _, 0, 'VK_LMENU'), _KM(ScanCode.MetaLeft, 'MetaLeft', KeyCode.Meta, _, 0, 'VK_LWIN'), _KM(ScanCode.ControlRight, 'ControlRight', KeyCode.Ctrl, _, 0, 'VK_RCONTROL'), _KM(ScanCode.ShiftRight, 'ShiftRight', KeyCode.Shift, _, 0, 'VK_RSHIFT'), _KM(ScanCode.AltRight, 'AltRight', KeyCode.Alt, _, 0, 'VK_RMENU'), _KM(ScanCode.MetaRight, 'MetaRight', KeyCode.Meta, _, 0, 'VK_RWIN'), ] SCANCODE_TO_STRING: Dict[ScanCode, str] = {} SCANCODE_FROM_LOWERCASE_STRING: Dict[str, ScanCode] = {} KEYCODE_TO_STRING: Dict[KeyCode, str] = {} KEYCODE_FROM_LOWERCASE_STRING: Dict[str, KeyCode] = { # two special cases for assigning os-specific strings to the meta key 'win': KeyCode.Meta, 'cmd': KeyCode.Meta, } # key symbols on all platforms KEY_SYMBOLS: dict[KeyCode, str] = { KeyCode.Shift: "⇧", KeyCode.LeftArrow: "â†", KeyCode.RightArrow: "→", KeyCode.UpArrow: "↑", KeyCode.DownArrow: "↓", KeyCode.Backspace: "⌫", KeyCode.Delete: "⌦", KeyCode.Tab: "⇥", KeyCode.Escape: "⎋", KeyCode.Enter: "↵", KeyCode.Space: "â£", KeyCode.CapsLock: "⇪", } # key symbols mappings per platform OS_KEY_SYMBOLS: dict[OperatingSystem, dict[KeyCode, str]] = { OperatingSystem.WINDOWS: {**KEY_SYMBOLS, KeyCode.Meta: "⊞"}, OperatingSystem.LINUX: {**KEY_SYMBOLS, KeyCode.Meta: "Super"}, OperatingSystem.MACOS: { **KEY_SYMBOLS, KeyCode.Ctrl: "⌃", KeyCode.Alt: "⌥", KeyCode.Meta: "⌘", }, } # key names mappings per platform OS_KEY_NAMES: dict[OperatingSystem, dict[KeyCode, str]] = { OperatingSystem.WINDOWS: {KeyCode.Meta: "Win"}, OperatingSystem.LINUX: {KeyCode.Meta: "Super"}, OperatingSystem.MACOS: { KeyCode.Ctrl: "Control", KeyCode.Alt: "Option", KeyCode.Meta: "Cmd", }, } seen_scancodes: Set[ScanCode] = set() seen_keycodes: Set[KeyCode] = set() for i, km in enumerate(_MAPPINGS): if km.scancode not in seen_scancodes: seen_scancodes.add(km.scancode) SCANCODE_TO_STRING[km.scancode] = km.scanstr SCANCODE_FROM_LOWERCASE_STRING[km.scanstr.lower()] = km.scancode if km.keycode not in seen_keycodes: seen_keycodes.add(km.keycode) if not km.keystr: # pragma: no cover raise ValueError( f"String representation missing for key code {km.keycode!r} " f"around scan code {km.scancode!r} at line {i + 1}" ) KEYCODE_TO_STRING[km.keycode] = km.keystr KEYCODE_FROM_LOWERCASE_STRING[km.keystr.lower()] = km.keycode if km.eventcode: _EVENTCODE_TO_KEYCODE[km.eventcode] = km.keycode if km.virtual_key: _NATIVE_WINDOWS_VK_TO_KEYCODE[km.virtual_key] = km.keycode def _keycode_to_string(keycode: KeyCode) -> str: """Return the string representation of a KeyCode.""" # sourcery skip return KEYCODE_TO_STRING.get(keycode, "") def _keycode_from_string(keystr: str) -> KeyCode: """Return KeyCode for a given string.""" # sourcery skip return KEYCODE_FROM_LOWERCASE_STRING.get(str(keystr).lower(), KeyCode.UNKNOWN) def _keycode_to_os_symbol(keycode: KeyCode, os: OperatingSystem) -> str: """Return key symbol for an OS for a given KeyCode.""" if keycode in (symbols := OS_KEY_SYMBOLS.get(os, {})): return symbols[keycode] return str(keycode) def _keycode_to_os_name(keycode: KeyCode, os: OperatingSystem) -> str: """Return key name for an OS for a given KeyCode.""" if keycode in (names := OS_KEY_NAMES.get(os, {})): return names[keycode] return str(keycode) def _scancode_to_string(scancode: ScanCode) -> str: """Return the string representation of a ScanCode.""" # sourcery skip return SCANCODE_TO_STRING.get(scancode, "") def _scancode_from_string(scanstr: str) -> ScanCode: """Return ScanCode for a given string.""" # sourcery skip return SCANCODE_FROM_LOWERCASE_STRING.get( str(scanstr).lower(), ScanCode.UNIDENTIFIED ) return ( _keycode_to_string, _keycode_from_string, _keycode_to_os_symbol, _keycode_to_os_name, _scancode_to_string, _scancode_from_string, ) ( keycode_to_string, keycode_from_string, keycode_to_os_symbol, keycode_to_os_name, scancode_to_string, scancode_from_string, ) = _build_maps() # fmt: on # Keys with modifiers are expressed # with a 16-bit binary encoding # # 1111 11 # 5432 1098 7654 3210 # ---- CSAW KKKK KKKK # C = bit 11 -> ctrlCmd flag # S = bit 10 -> shift flag # A = bit 9 -> alt flag # W = bit 8 -> winCtrl flag # K = bits 0-7 -> key code class KeyMod(IntFlag): """A Flag indicating keyboard modifiers.""" NONE = 0 CtrlCmd = 1 << 11 # command on a mac, control on windows Shift = 1 << 10 # shift key Alt = 1 << 9 # alt option WinCtrl = 1 << 8 # meta key on windows, ctrl key on mac @overload # type: ignore def __or__(self, other: "KeyMod") -> "KeyMod": ... @overload def __or__(self, other: KeyCode) -> "KeyCombo": ... @overload def __or__(self, other: int) -> int: ... def __or__( self, other: Union["KeyMod", KeyCode, int] ) -> Union["KeyMod", "KeyCombo", int]: if isinstance(other, self.__class__): return self.__class__(self._value_ | other._value_) if isinstance(other, KeyCode): return KeyCombo(self, other) return NotImplemented # pragma: no cover class KeyCombo(int): """KeyCombo is an integer combination of one or more. [`KeyMod`][app_model.types.KeyMod] and [`KeyCode`][app_model.types.KeyCode]. """ def __new__( cls: Type["KeyCombo"], modifiers: KeyMod, key: KeyCode = KeyCode.UNKNOWN ) -> "KeyCombo": return super().__new__(cls, int(modifiers) | int(key)) def __init__(self, modifiers: KeyMod, key: KeyCode = KeyCode.UNKNOWN): self._modifiers = modifiers self._key = key def __repr__(self) -> str: name = self.__class__.__name__ mods_repr = repr(self._modifiers).split(":", 1)[0].split(".", 1)[1] return f"<{name}.{mods_repr}|{self._key.name}: {int(self)}>" class KeyChord(int): """KeyChord is an integer combination of two key combos. It could be two [`KeyCombo`][app_model.types.KeyCombo] [`KeyCode`][app_model.types.KeyCode], or [int][]. Parameters ---------- first_part : KeyCombo | int The first part of the chord. second_part : KeyCombo | int The second part of the chord. """ def __new__(cls: Type["KeyChord"], first_part: int, second_part: int) -> "KeyChord": # shift the second part 16 bits to the left chord_part = (second_part & 0x0000FFFF) << 16 # then combine then to make the full chord return super().__new__(cls, first_part | chord_part) def __init__(self, first_part: int, second_part: int): self._first_part = first_part self._second_part = second_part def __repr__(self) -> str: return f"KeyChord({self._first_part!r}, {self._second_part!r})" app-model-0.3.0/src/app_model/types/_keys/_keybindings.py000066400000000000000000000271641467241150700234350ustar00rootroot00000000000000import re from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Tuple from pydantic_compat import PYDANTIC2, BaseModel, Field, model_validator from app_model.types._constants import OperatingSystem from ._key_codes import KeyChord, KeyCode, KeyMod if TYPE_CHECKING: from pydantic.annotated_handlers import GetCoreSchemaHandler from pydantic_core import core_schema class SimpleKeyBinding(BaseModel): """Represent a simple combination modifier(s) and a key, e.g. Ctrl+A.""" ctrl: bool = Field(False, description='Whether the "Ctrl" modifier is active.') shift: bool = Field(False, description='Whether the "Shift" modifier is active.') alt: bool = Field(False, description='Whether the "Alt" modifier is active.') meta: bool = Field(False, description='Whether the "Meta" modifier is active.') key: Optional[KeyCode] = Field( None, description="The key that is pressed (e.g. `KeyCode.A`)" ) # def hash_code(self) -> str: # used by vscode for caching during keybinding resolution def is_modifier_key(self) -> bool: """Return true if this is a modifier key.""" return self.key in ( KeyCode.Alt, KeyCode.Shift, KeyCode.Ctrl, KeyCode.Meta, KeyCode.UNKNOWN, ) def __str__(self) -> str: """Get a normalized string representation (constant to all OSes) of this SimpleKeyBinding.""" out = "" if self.ctrl: out += "Ctrl+" if self.shift: out += "Shift+" if self.alt: out += "Alt+" if self.meta: out += "Meta+" if self.key: out += str(self.key) return out def __eq__(self, other: Any) -> bool: # sourcery skip: remove-unnecessary-cast if not isinstance(other, SimpleKeyBinding): try: if (other := SimpleKeyBinding._parse_input(other)) is None: return NotImplemented except TypeError: # pragma: no cover # can happen with pydantic v2 return NotImplemented return bool( self.ctrl == other.ctrl and self.shift == other.shift and self.alt == other.alt and self.meta == other.meta and self.key == other.key ) @classmethod def from_str(cls, key_str: str) -> "SimpleKeyBinding": """Parse a string into a SimpleKeyBinding.""" mods, remainder = _parse_modifiers(key_str.strip()) key = KeyCode.from_string(remainder) return cls(**mods, key=key) @classmethod def from_int( cls, key_int: int, os: Optional[OperatingSystem] = None ) -> "SimpleKeyBinding": """Create a SimpleKeyBinding from an integer.""" ctrl_cmd = bool(key_int & KeyMod.CtrlCmd) win_ctrl = bool(key_int & KeyMod.WinCtrl) shift = bool(key_int & KeyMod.Shift) alt = bool(key_int & KeyMod.Alt) os = OperatingSystem.current() if os is None else os ctrl = win_ctrl if os.is_mac else ctrl_cmd meta = ctrl_cmd if os.is_mac else win_ctrl key = key_int & 0x000000FF # keycode mask return cls(ctrl=ctrl, shift=shift, alt=alt, meta=meta, key=key) def __int__(self) -> int: return int(self.to_int()) def __hash__(self) -> int: return hash((self.ctrl, self.shift, self.alt, self.meta, self.key)) def to_int(self, os: Optional[OperatingSystem] = None) -> int: """Convert this SimpleKeyBinding to an integer representation.""" os = OperatingSystem.current() if os is None else os mods: KeyMod = KeyMod.NONE if self.ctrl: mods |= KeyMod.WinCtrl if os.is_mac else KeyMod.CtrlCmd if self.shift: mods |= KeyMod.Shift if self.alt: mods |= KeyMod.Alt if self.meta: mods |= KeyMod.CtrlCmd if os.is_mac else KeyMod.WinCtrl return mods | (self.key or 0) def _mods2keycodes(self) -> List[KeyCode]: """Create KeyCode instances list of modifiers from this SimpleKeyBinding.""" mods = [] if self.ctrl: mods.append(KeyCode.Ctrl) if self.shift: mods.append(KeyCode.Shift) if self.alt: mods.append(KeyCode.Alt) if self.meta: mods.append(KeyCode.Meta) return mods def to_text( self, os: Optional[OperatingSystem] = None, use_symbols: bool = False, joinchar: str = "+", ) -> str: """Get a user-facing string representation of this SimpleKeyBinding. Optionally, the string representation can be constructed with symbols like ↵ for Enter or OS specific ones like ⌘ for Meta on MacOS. If no symbols should be used, the string representation will use the OS specific names for the keys like `Cmd` for Meta or `Option` for Ctrl on MacOS. Also, a join character can be defined. By default `+` is used. """ os = OperatingSystem.current() if os is None else os keybinding_elements = [*self._mods2keycodes()] if self.key: keybinding_elements.append(self.key) return joinchar.join( kbe.os_symbol(os=os) if use_symbols else kbe.os_name(os=os) for kbe in keybinding_elements ) @classmethod def _parse_input(cls, v: Any) -> "SimpleKeyBinding": if isinstance(v, SimpleKeyBinding): return v if isinstance(v, str): return cls.from_str(v) if isinstance(v, int): return cls.from_int(v) raise TypeError(f"invalid type: {type(v)}") @model_validator(mode="after") # type: ignore @classmethod def _model_val(cls, instance: "SimpleKeyBinding") -> "SimpleKeyBinding": return cls._parse_input(instance) MIN1 = {"min_length": 1} if PYDANTIC2 else {"min_items": 1} class KeyBinding: """KeyBinding. May be a multi-part "Chord" (e.g. 'Ctrl+K Ctrl+C'). This is the primary representation of a fully resolved keybinding. For consistency in the downstream API, it should be preferred to [`SimpleKeyBinding`][app_model.types.SimpleKeyBinding], even when there is only a single part in the keybinding (i.e. when it is not a chord.) Chords (two separate keypress actions) are expressed as a string by separating the two keypress codes with a space. For example, 'Ctrl+K Ctrl+C'. Parameters ---------- parts : List[SimpleKeyBinding] The parts of the keybinding. There must be at least one part. """ parts: List[SimpleKeyBinding] = Field(..., **MIN1) # type: ignore def __init__(self, *, parts: List[SimpleKeyBinding]): self.parts = parts def __str__(self) -> str: """Get a normalized string representation (constant to all OSes) of this KeyBinding.""" return " ".join(str(part) for part in self.parts) def __repr__(self) -> str: return f"<{self.__class__.__name__} at {hex(id(self))}: {self}>" def __eq__(self, other: Any) -> bool: if isinstance(other, KeyBinding): return self.parts == other.parts return NotImplemented def __len__(self) -> int: return len(self.parts) @property def part0(self) -> SimpleKeyBinding: """Return the first part of this keybinding. All keybindings have at least one part. """ return self.parts[0] @classmethod def from_str(cls, key_str: str) -> "KeyBinding": """Parse a string into a SimpleKeyBinding.""" parts = [SimpleKeyBinding.from_str(part) for part in key_str.split()] return cls(parts=parts) @classmethod def from_int( cls, key_int: int, os: Optional[OperatingSystem] = None ) -> "KeyBinding": """Create a KeyBinding from an integer.""" # a multi keybinding is represented as an integer # with the first_part in the lowest 16 bits, # the second_part in the next 16 bits, etc. first_part = key_int & 0x0000FFFF chord_part = (key_int & 0xFFFF0000) >> 16 if chord_part != 0: return cls( parts=[ SimpleKeyBinding.from_int(first_part, os), SimpleKeyBinding.from_int(chord_part, os), ] ) return cls(parts=[SimpleKeyBinding.from_int(first_part, os)]) def to_int(self, os: Optional[OperatingSystem] = None) -> int: """Convert this KeyBinding to an integer representation.""" if len(self.parts) > 2: # pragma: no cover raise NotImplementedError( "Cannot represent chords with more than 2 parts as int" ) os = OperatingSystem.current() if os is None else os parts = [part.to_int(os) for part in self.parts] if len(parts) == 2: return KeyChord(*parts) return parts[0] def to_text( self, os: Optional[OperatingSystem] = None, use_symbols: bool = False, joinchar: str = "+", ) -> str: """Get a text representation of this KeyBinding. Optionally, the string representation can be constructed with symbols like ↵ for Enter or OS specific ones like ⌘ for Meta on MacOS. If no symbols should be used, the string representation will use the OS specific names for the keys like `Cmd` for Meta or `Option` for Ctrl on MacOS. Also, a join character can be defined. By default `+` is used. """ return " ".join( part.to_text(os=os, use_symbols=use_symbols, joinchar=joinchar) for part in self.parts ) def __int__(self) -> int: return int(self.to_int()) def __hash__(self) -> int: return hash(tuple(self.parts)) @classmethod def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: yield cls.validate # pragma: no cover @classmethod def __get_pydantic_core_schema__( cls, source: type, handler: "GetCoreSchemaHandler" ) -> "core_schema.CoreSchema": from pydantic_core import core_schema return core_schema.no_info_plain_validator_function( cls.validate, serialization=core_schema.to_string_ser_schema() ) @classmethod def validate(cls, v: Any) -> "KeyBinding": """Validate a SimpleKeyBinding.""" if isinstance(v, KeyBinding): return v if isinstance(v, SimpleKeyBinding): return cls(parts=[v]) if isinstance(v, int): return cls.from_int(v) if isinstance(v, str): return cls.from_str(v) raise TypeError("invalid keybinding") # pragma: no cover _re_ctrl = re.compile(r"(ctrl|control|ctl|⌃|\^)[\+|\-]") _re_shift = re.compile(r"(shift|⇧)[\+|\-]") _re_alt = re.compile(r"(alt|opt|option|⌥)[\+|\-]") _re_meta = re.compile(r"(meta|super|win|windows|⊞|cmd|command|⌘)[\+|\-]") def _parse_modifiers(input: str) -> Tuple[Dict[str, bool], str]: """Parse modifiers from a string (case insensitive). modifiers must start at the beginning of the string, and be separated by "+" or "-". e.g. "ctrl+shift+alt+K" or "Ctrl-Cmd-K" """ remainder = input.lower() patterns = {"ctrl": _re_ctrl, "shift": _re_shift, "alt": _re_alt, "meta": _re_meta} mods = dict.fromkeys(patterns, False) while True: saw_modifier = False for key, ptrn in patterns.items(): if m := ptrn.match(remainder): remainder = remainder[m.span()[1] :] mods[key] = True saw_modifier = True break if not saw_modifier: break return mods, remainder app-model-0.3.0/src/app_model/types/_keys/_standard_bindings.py000066400000000000000000000173331467241150700246010ustar00rootroot00000000000000from collections import namedtuple from enum import Enum, auto from typing import TYPE_CHECKING, Dict from ._key_codes import KeyCode, KeyMod if TYPE_CHECKING: from .._keybinding_rule import KeyBindingRule class StandardKeyBinding(Enum): AddTab = auto() Back = auto() Bold = auto() Cancel = auto() Close = auto() Copy = auto() Cut = auto() Delete = auto() DeleteCompleteLine = auto() DeleteEndOfLine = auto() DeleteEndOfWord = auto() DeleteStartOfWord = auto() Deselect = auto() Find = auto() FindNext = auto() FindPrevious = auto() Forward = auto() FullScreen = auto() HelpContents = auto() Italic = auto() MoveToEndOfDocument = auto() MoveToEndOfLine = auto() MoveToNextChar = auto() MoveToNextLine = auto() MoveToNextPage = auto() MoveToNextWord = auto() MoveToPreviousChar = auto() MoveToPreviousLine = auto() MoveToPreviousPage = auto() MoveToPreviousWord = auto() MoveToStartOfDocument = auto() MoveToStartOfLine = auto() New = auto() NextChild = auto() Open = auto() Paste = auto() Preferences = auto() PreviousChild = auto() Print = auto() Quit = auto() Redo = auto() Refresh = auto() Replace = auto() Save = auto() SaveAs = auto() SelectAll = auto() SelectEndOfDocument = auto() SelectEndOfLine = auto() SelectNextChar = auto() SelectNextLine = auto() SelectNextPage = auto() SelectNextWord = auto() SelectPreviousChar = auto() SelectPreviousLine = auto() SelectPreviousPage = auto() SelectPreviousWord = auto() SelectStartOfDocument = auto() SelectStartOfLine = auto() Underline = auto() Undo = auto() WhatsThis = auto() OriginalSize = auto() ZoomIn = auto() ZoomOut = auto() def to_keybinding_rule(self) -> "KeyBindingRule": """Return KeyBindingRule for this StandardKeyBinding.""" from .._keybinding_rule import KeyBindingRule return KeyBindingRule(**_STANDARD_KEY_MAP[self]) _ = None SK = namedtuple("SK", "sk, primary, win, mac, gnome", defaults=(_, _, _, _, _)) # fmt: off # flake8: noqa _STANDARD_KEYS = [ SK(StandardKeyBinding.AddTab, KeyMod.CtrlCmd | KeyCode.KeyT), SK(StandardKeyBinding.Back, KeyMod.Alt | KeyCode.LeftArrow, _, KeyMod.CtrlCmd | KeyCode.BracketLeft), SK(StandardKeyBinding.Bold, KeyMod.CtrlCmd | KeyCode.KeyB), SK(StandardKeyBinding.Cancel, KeyCode.Escape), SK(StandardKeyBinding.Close, KeyMod.CtrlCmd | KeyCode.KeyW), SK(StandardKeyBinding.Copy, KeyMod.CtrlCmd | KeyCode.KeyC), SK(StandardKeyBinding.Cut, KeyMod.CtrlCmd | KeyCode.KeyX), SK(StandardKeyBinding.Delete, KeyCode.Delete), SK(StandardKeyBinding.DeleteCompleteLine, _, _, _, KeyMod.CtrlCmd | KeyCode.KeyU), SK(StandardKeyBinding.DeleteEndOfLine, _, _, _, KeyMod.CtrlCmd | KeyCode.KeyK), SK(StandardKeyBinding.DeleteEndOfWord, _, KeyMod.CtrlCmd | KeyCode.Delete, _, KeyMod.CtrlCmd | KeyCode.Delete), SK(StandardKeyBinding.DeleteStartOfWord, _, KeyMod.CtrlCmd | KeyCode.Backspace, KeyMod.Alt | KeyCode.Backspace, KeyMod.CtrlCmd | KeyCode.Backspace), SK(StandardKeyBinding.Deselect, _, _, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA), SK(StandardKeyBinding.Find, KeyMod.CtrlCmd | KeyCode.KeyF), SK(StandardKeyBinding.FindNext, KeyMod.CtrlCmd | KeyCode.KeyG), SK(StandardKeyBinding.FindPrevious, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG), SK(StandardKeyBinding.Forward, _, KeyMod.Alt | KeyCode.RightArrow, KeyMod.CtrlCmd | KeyCode.BracketRight, KeyMod.Alt | KeyCode.RightArrow), SK(StandardKeyBinding.FullScreen, _, KeyMod.Alt | KeyCode.Enter, KeyMod.WinCtrl | KeyMod.CtrlCmd | KeyCode.KeyF, KeyMod.CtrlCmd | KeyCode.F11), SK(StandardKeyBinding.HelpContents, KeyCode.F1, _, KeyMod.CtrlCmd | KeyCode.Slash), SK(StandardKeyBinding.Italic, KeyMod.CtrlCmd | KeyCode.KeyI), SK(StandardKeyBinding.MoveToEndOfDocument, KeyMod.CtrlCmd | KeyCode.End, _, KeyMod.CtrlCmd | KeyCode.DownArrow), SK(StandardKeyBinding.MoveToEndOfLine, KeyCode.End, _, KeyMod.CtrlCmd | KeyCode.RightArrow), SK(StandardKeyBinding.MoveToNextChar, KeyCode.RightArrow), SK(StandardKeyBinding.MoveToNextLine, KeyCode.DownArrow), SK(StandardKeyBinding.MoveToNextPage, KeyCode.PageDown), SK(StandardKeyBinding.MoveToNextWord, KeyMod.CtrlCmd | KeyCode.RightArrow, _, KeyMod.Alt | KeyCode.RightArrow), SK(StandardKeyBinding.MoveToPreviousChar, KeyCode.LeftArrow), SK(StandardKeyBinding.MoveToPreviousLine, KeyCode.UpArrow), SK(StandardKeyBinding.MoveToPreviousPage, KeyCode.PageUp), SK(StandardKeyBinding.MoveToPreviousWord, KeyMod.CtrlCmd | KeyCode.LeftArrow, _, KeyMod.Alt | KeyCode.LeftArrow), SK(StandardKeyBinding.MoveToStartOfDocument, KeyMod.CtrlCmd | KeyCode.Home, _, KeyCode.Home), SK(StandardKeyBinding.MoveToStartOfLine, KeyCode.Home, _, KeyMod.CtrlCmd | KeyCode.LeftArrow), SK(StandardKeyBinding.New, KeyMod.CtrlCmd | KeyCode.KeyN), SK(StandardKeyBinding.NextChild, KeyMod.CtrlCmd | KeyCode.Tab, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketRight), SK(StandardKeyBinding.Open, KeyMod.CtrlCmd | KeyCode.KeyO), SK(StandardKeyBinding.Paste, KeyMod.CtrlCmd | KeyCode.KeyV), SK(StandardKeyBinding.Preferences, KeyMod.CtrlCmd | KeyCode.Comma), SK(StandardKeyBinding.PreviousChild, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Tab, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.BracketLeft), SK(StandardKeyBinding.Print, KeyMod.CtrlCmd | KeyCode.KeyP), SK(StandardKeyBinding.Quit, KeyMod.CtrlCmd | KeyCode.KeyQ), SK(StandardKeyBinding.Redo, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyZ, KeyMod.CtrlCmd | KeyCode.KeyY), SK(StandardKeyBinding.Refresh, KeyMod.CtrlCmd | KeyCode.KeyR), SK(StandardKeyBinding.Replace, KeyMod.CtrlCmd | KeyCode.KeyH), SK(StandardKeyBinding.Save, KeyMod.CtrlCmd | KeyCode.KeyS), SK(StandardKeyBinding.SaveAs, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyS), SK(StandardKeyBinding.SelectAll, KeyMod.CtrlCmd | KeyCode.KeyA), SK(StandardKeyBinding.SelectEndOfDocument, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.End), SK(StandardKeyBinding.SelectEndOfLine, KeyMod.Shift | KeyCode.End, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.RightArrow), SK(StandardKeyBinding.SelectNextChar, KeyMod.Shift | KeyCode.RightArrow), SK(StandardKeyBinding.SelectNextLine, KeyMod.Shift | KeyCode.DownArrow), SK(StandardKeyBinding.SelectNextPage, KeyMod.Shift | KeyCode.PageDown), SK(StandardKeyBinding.SelectNextWord, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.RightArrow, _, KeyMod.Alt | KeyMod.Shift | KeyCode.RightArrow), SK(StandardKeyBinding.SelectPreviousChar, KeyMod.Shift | KeyCode.LeftArrow), SK(StandardKeyBinding.SelectPreviousLine, KeyMod.Shift | KeyCode.UpArrow), SK(StandardKeyBinding.SelectPreviousPage, KeyMod.Shift | KeyCode.PageUp), SK(StandardKeyBinding.SelectPreviousWord, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.LeftArrow, _, KeyMod.Alt | KeyMod.Shift | KeyCode.LeftArrow), SK(StandardKeyBinding.SelectStartOfDocument, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Home), SK(StandardKeyBinding.SelectStartOfLine, KeyMod.Shift | KeyCode.Home, _, KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.LeftArrow), SK(StandardKeyBinding.Underline, KeyMod.CtrlCmd | KeyCode.KeyU), SK(StandardKeyBinding.Undo, KeyMod.CtrlCmd | KeyCode.KeyZ), SK(StandardKeyBinding.WhatsThis, KeyMod.Shift | KeyCode.F1), SK(StandardKeyBinding.OriginalSize, KeyMod.CtrlCmd | KeyCode.Digit0), SK(StandardKeyBinding.ZoomIn, KeyMod.CtrlCmd | KeyCode.Equal), SK(StandardKeyBinding.ZoomOut, KeyMod.CtrlCmd | KeyCode.Minus), ] # fmt: on _STANDARD_KEY_MAP: Dict[StandardKeyBinding, Dict[str, str]] = { nt.sk: {"primary": nt.primary, "win": nt.win, "mac": nt.mac, "linux": nt.gnome} for nt in _STANDARD_KEYS } app-model-0.3.0/src/app_model/types/_menu_rule.py000066400000000000000000000110531467241150700217760ustar00rootroot00000000000000from typing import ( Any, Callable, Generator, Optional, Type, TypedDict, Union, ) from pydantic_compat import Field, field_validator, model_validator from app_model import expressions from ._base import _BaseModel from ._command_rule import CommandRule from ._icon import Icon class MenuItemBase(_BaseModel): """Data representing where and when a menu item should be shown.""" when: Optional[expressions.Expr] = Field( None, description="(Optional) Condition which must be true to show the item.", ) group: Optional[str] = Field( None, description="(Optional) Menu group to which this item should be added. Menu " "groups are sortable strings (like `'1_cutandpaste'`). 'navigation' is a " "special group that always appears at the top of a menu. If not provided, " "the item is added in the last group of the menu.", ) order: Optional[float] = Field( None, description="(Optional) Order of the item *within* its group. Note, order is " "not part of the plugin schema, plugins may provide it using the group key " "and the syntax 'group@order'. If not provided, items are sorted by title.", ) @classmethod def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: yield cls._validate @classmethod def _validate(cls: Type["MenuItemBase"], v: Any) -> "MenuItemBase": """Validate icon.""" if isinstance(v, MenuItemBase): return v if isinstance(v, dict): if "command" in v: return MenuItem(**v) if "id" in v: return MenuRule(**v) if "submenu" in v: return SubmenuItem(**v) raise ValueError(f"Invalid menu item: {v!r}", cls) # pragma: no cover class MenuRule(MenuItemBase): """A MenuRule defines a menu location and conditions for presentation. It does not define an actual command. That is done in either `MenuItem` or `Action`. """ id: str = Field(..., description="Menu in which to place this item.") # for v1 @classmethod def _validate(cls: Type["MenuRule"], v: Any) -> Any: if isinstance(v, str): v = {"id": v} return super()._validate(v) # for v2 @model_validator(mode="before") def _validate_model(cls, v: Any) -> Any: """If a single string is provided, convert to a dict with `id` key.""" return {"id": v} if isinstance(v, str) else v class MenuItem(MenuItemBase): """Combination of a Command and conditions for menu presentation. This object is mostly constructed by `register_action` right before menu item registration. """ command: CommandRule = Field( ..., description="CommandRule to execute when this menu item is selected.", ) alt: Optional[CommandRule] = Field( None, description="(Optional) Alternate command to execute when this menu item is " "selected, (e.g. when the Alt-key is held when opening the menu)", ) @field_validator("command") def _simplify_command_rule(cls, v: Any) -> CommandRule: if isinstance(v, CommandRule): return v._as_command_rule() raise TypeError("command must be a CommandRule") # pragma: no cover class SubmenuItem(MenuItemBase): """Point to another Menu that will be displayed as a submenu.""" submenu: str = Field(..., description="Menu to insert as a submenu.") title: str = Field(..., description="Title of this submenu, shown in the UI.") icon: Optional[Icon] = Field( None, description="(Optional) Icon used to represent this submenu. " "These may be [iconify keys](https://icon-sets.iconify.design), " "such as `fa6-solid:arrow-down`, or " "[superqt.fonticon](https://pyapp-kit.github.io/superqt/utilities/fonticon/)" " keys, such as `fa6s.arrow_down`", ) enablement: Optional[expressions.Expr] = Field( None, description="(Optional) Condition which must be true to enable the submenu. " "Disabled submenus appear grayed out in the UI, and cannot be selected. By " "default, submenus are enabled.", ) class MenuRuleDict(TypedDict, total=False): """Typed dict for MenuRule kwargs. This mimics the pydantic `MenuRule` interface, but allows you to pass in a dict """ when: Optional[expressions.Expr] group: str order: Optional[float] id: str MenuRuleOrDict = Union[MenuRule, MenuRuleDict] MenuOrSubmenu = Union[MenuItem, SubmenuItem] app-model-0.3.0/src/app_model/types/_utils.py000066400000000000000000000033321467241150700211440ustar00rootroot00000000000000import re from importlib import import_module from typing import Any _identifier_plus_dash = "(?:[a-zA-Z_][a-zA-Z_0-9-]+)" _dotted_name = f"(?:(?:{_identifier_plus_dash}\\.)*{_identifier_plus_dash})" PYTHON_NAME_PATTERN = re.compile(f"^({_dotted_name}):({_dotted_name})$") def _validate_python_name(name: str) -> str: """Assert that `name` is a valid python name: e.g. `module.submodule:funcname`.""" if name and not PYTHON_NAME_PATTERN.match(name): msg = ( f"{name!r} is not a valid python_name. A python_name must " "be of the form '{obj.__module__}:{obj.__qualname__}' (e.g. " "'my_package.a_module:some_function')." ) if ".." in name: # pragma: no cover *_, a, b = name.split("..") a = a.split(":")[-1] msg += ( " Note: functions defined in local scopes are not yet supported. " f"Please move function {b!r} to the global scope of module {a!r}" ) raise ValueError(msg) return name def import_python_name(python_name: str) -> Any: """Import object from a fully qualified python name. Examples -------- >>> import_python_name("my_package.a_module:some_function") >>> import_python_name("pydantic:BaseModel") """ _validate_python_name(python_name) # shows the best error message if match := PYTHON_NAME_PATTERN.match(python_name): module_name, funcname = match.groups() mod = import_module(module_name) return getattr(mod, funcname) raise ValueError( # pragma: no cover f"Could not parse python_name: {python_name!r}" ) app-model-0.3.0/tests/000077500000000000000000000000001467241150700145415ustar00rootroot00000000000000app-model-0.3.0/tests/conftest.py000066400000000000000000000162731467241150700167510ustar00rootroot00000000000000from __future__ import annotations import sys from pathlib import Path from typing import TYPE_CHECKING from unittest.mock import Mock import pytest from app_model import Action, Application from app_model.types import KeyCode, KeyMod, SubmenuItem if TYPE_CHECKING: from typing import Iterator, NoReturn try: from fonticon_fa6 import FA6S UNDO_ICON = FA6S.rotate_left except ImportError: UNDO_ICON = "fa6-solid:undo" FIXTURES = Path(__file__).parent / "fixtures" class Menus: FILE = "file" EDIT = "edit" HELP = "help" FILE_OPEN_FROM = "file/open_from" class Commands: TOP = "top" OPEN = "open" UNDO = "undo" REDO = "redo" COPY = "copy" PASTE = "paste" TOGGLE_THING = "toggle_thing" OPEN_FROM_A = "open.from_a" OPEN_FROM_B = "open.from_b" UNIMPORTABLE = "unimportable" NOT_CALLABLE = "not.callable" RAISES = "raises.error" def _raise_an_error() -> NoReturn: raise ValueError("This is an error") class Mocks: def __init__(self) -> None: self.open = Mock(name=Commands.OPEN) self.undo = Mock(name=Commands.UNDO) self.copy = Mock(name=Commands.COPY) self.paste = Mock(name=Commands.PASTE) self.open_from_a = Mock(name=Commands.OPEN_FROM_A) self.open_from_b = Mock(name=Commands.OPEN_FROM_B) @property def redo(self) -> Mock: """This tests that we can lazily import a callback. There is a function called `run_me` in fixtures/fake_module.py that calls the global mock in that module. In the redo action below, we declare: `callback="fake_module:run_me"` So, whenever the redo action is triggered, it should import that module, and then call the mock. We can also access it here at `mocks.redo`... but the fixtures directory must be added to sys path during the test (as we do below) """ try: from fake_module import GLOBAL_MOCK return GLOBAL_MOCK except ImportError as e: raise ImportError( "This mock must be run with the fixutres directory added to sys.path." ) from e class FullApp(Application): Menus = Menus Commands = Commands def __init__(self, name: str) -> None: super().__init__(name) self.mocks = Mocks() def build_app(name: str = "complete_test_app") -> FullApp: app = FullApp(name) app.menus.append_menu_items( [ ( Menus.FILE, SubmenuItem( submenu=Menus.FILE_OPEN_FROM, title="Open From...", icon="fa6s.folder-open", when="not something_open", enablement="friday", ), ) ] ) actions: list[Action] = [ Action( id=Commands.OPEN, title="Open...", callback=app.mocks.open, menus=[{"id": Menus.FILE}], keybindings=[{"primary": "Ctrl+O"}], ), # putting these above undo redo to make sure that group sorting works Action( id=Commands.COPY, title="Copy", icon="fa6-solid:copy", # iconify font style works too callback=app.mocks.copy, menus=[{"id": Menus.EDIT, "group": "2_copy_paste"}], keybindings=[{"primary": KeyMod.CtrlCmd | KeyCode.KeyC}], ), Action( id=Commands.PASTE, title="Paste", icon="fa6s.paste", callback=app.mocks.paste, menus=[{"id": Menus.EDIT, "group": "2_copy_paste"}], keybindings=[{"primary": "Ctrl+V", "mac": "Cmd+V"}], ), # putting this above UNDO to make sure that order sorting works Action( id=Commands.REDO, title="Redo", tooltip="Redo it!", icon="fa6-solid:rotate-right", enablement="allow_undo_redo", callback="fake_module:run_me", # this is a function in fixtures keybindings=[{"primary": "Ctrl+Shift+Z"}], menus=[ { "id": Menus.EDIT, "group": "1_undo_redo", "order": 1, "when": "not something_to_undo", } ], ), Action( id=Commands.UNDO, tooltip="Undo it!", title="Undo", icon=UNDO_ICON, # testing alternate way to specify icon enablement="allow_undo_redo", callback=app.mocks.undo, keybindings=[{"primary": "Ctrl+Z"}], menus=[ { "id": Menus.EDIT, "group": "1_undo_redo", "order": 0, "when": "something_to_undo", } ], ), # test the navigation key Action( id=Commands.TOP, title="AtTop", callback=lambda: None, menus=[{"id": Menus.EDIT, "group": "navigation"}], ), # test submenus Action( id=Commands.OPEN_FROM_A, title="Open from A", callback=app.mocks.open_from_a, menus=[{"id": Menus.FILE_OPEN_FROM}], ), Action( id=Commands.OPEN_FROM_B, title="Open from B", callback=app.mocks.open_from_b, menus=[{"id": Menus.FILE_OPEN_FROM}], ), Action( id=Commands.UNIMPORTABLE, title="Can't be found", callback="unresolvable:function", ), Action( id=Commands.NOT_CALLABLE, title="Will Never Work", callback="fake_module:attr", ), Action( id=Commands.RAISES, title="Will raise an error", callback=_raise_an_error, ), Action( id=Commands.TOGGLE_THING, title="Toggle Thing", callback=lambda: None, menus=[{"id": Menus.HELP}], toggled="thing_toggled", ), ] for action in actions: app.register_action(action) return app @pytest.fixture def full_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[Application]: """Premade application.""" try: app = build_app("complete_test_app") with monkeypatch.context() as m: # mock path to add fake_module m.setattr(sys, "path", [str(FIXTURES), *sys.path]) # make sure it's not already in sys.modules sys.modules.pop("fake_module", None) yield app # clear the global mock if it's been called app.mocks.redo.reset_mock() finally: Application.destroy("complete_test_app") @pytest.fixture def simple_app() -> Iterator[Application]: app = Application("test") app.commands_changed = Mock() app.commands.registered.connect(app.commands_changed) app.keybindings_changed = Mock() app.keybindings.registered.connect(app.keybindings_changed) app.menus_changed = Mock() app.menus.menus_changed.connect(app.menus_changed) yield app Application.destroy("test") assert "test" not in Application._instances app-model-0.3.0/tests/fixtures/000077500000000000000000000000001467241150700164125ustar00rootroot00000000000000app-model-0.3.0/tests/fixtures/fake_module.py000066400000000000000000000002251467241150700212360ustar00rootroot00000000000000from unittest.mock import Mock GLOBAL_MOCK = Mock(name="GLOBAL") def run_me() -> bool: GLOBAL_MOCK() return True attr = "not a callble" app-model-0.3.0/tests/test_actions.py000066400000000000000000000075111467241150700176160ustar00rootroot00000000000000from typing import List import pytest from app_model import Application from app_model.registries import register_action from app_model.types import Action, KeyBinding PRIMARY_KEY = "ctrl+a" OS_KEY = "ctrl+b" MENUID = "some.menu.id" KWARGS = [ {}, {"enablement": "x == 1"}, {"menus": [MENUID]}, # test that we can pass menus as a single string too {"enablement": "3 >= 1", "menus": [{"id": MENUID}]}, {"keybindings": [{"primary": PRIMARY_KEY}]}, { "keybindings": [ {"primary": PRIMARY_KEY, "mac": OS_KEY, "win": OS_KEY, "linux": OS_KEY} ] }, {"keybindings": [{"primary": "ctrl+a"}], "menus": [{"id": MENUID}]}, {"palette": False}, ] @pytest.mark.parametrize("kwargs", KWARGS) @pytest.mark.parametrize("mode", ["str", "decorator", "action"]) def test_register_action_decorator( kwargs: dict, simple_app: Application, mode: str ) -> None: # make sure mocks are working app = simple_app assert not list(app.commands) assert not list(app.keybindings) assert not list(app.menus) cmd_id = "cmd.id" kwargs["title"] = "Test title" # register the action if mode == "decorator": @register_action(app=app, id_or_action=cmd_id, **kwargs) def f1(): return "hi" assert f1() == "hi" # decorator returns the function else: def f2(): return "hi" if mode == "str": register_action(app=app, id_or_action=cmd_id, callback=f2, **kwargs) elif mode == "action": action = Action(id=cmd_id, callback=f2, **kwargs) app.register_action(action) # make sure the command is registered assert cmd_id in app.commands assert list(app.commands) # make sure an event was emitted signaling the command was registered app.commands_changed.assert_called_once_with(cmd_id) # type: ignore # make sure we can call the command, and that we can inject dependencies. assert app.commands.execute_command(cmd_id).result() == "hi" # make sure menus are registered if specified menus = kwargs.get("menus", []) if menus := kwargs.get("menus"): for entry in menus: id_ = entry if isinstance(entry, str) else entry["id"] assert id_ in app.menus app.menus_changed.assert_any_call({id_}) else: menus = list(app.menus) if kwargs.get("palette") is not False: assert app.menus.COMMAND_PALETTE_ID in app.menus assert len(menus) == 1 else: assert not list(app.menus) # make sure keybindings are registered if specified if keybindings := kwargs.get("keybindings"): for entry in keybindings: key = PRIMARY_KEY if len(entry) == 1 else OS_KEY # see KWARGS[5] key = KeyBinding.from_str(key) assert any(i.keybinding == key for i in app.keybindings) app.keybindings_changed.assert_called() # type: ignore else: assert not list(app.keybindings) # check that calling the dispose function removes everything. app.dispose() assert not list(app.commands) assert not list(app.keybindings) assert not list(app.menus) def test_errors(simple_app: Application): with pytest.raises(ValueError, match="'title' is required"): simple_app.register_action("cmd_id") # type: ignore with pytest.raises(TypeError, match="must be a string or an Action"): simple_app.register_action(None) # type: ignore def test_register_multiple_actions(simple_app: Application): actions: List[Action] = [ Action(id="cmd_id1", title="title1", callback=lambda: None), Action(id="cmd_id2", title="title2", callback=lambda: None), ] dispose = simple_app.register_actions(actions) assert len(simple_app.commands) == 2 dispose() assert not list(simple_app.commands) app-model-0.3.0/tests/test_app.py000066400000000000000000000077701467241150700167450ustar00rootroot00000000000000from __future__ import annotations import os import sys from typing import TYPE_CHECKING import pytest from app_model import Application from app_model.expressions import Context if TYPE_CHECKING: from conftest import FullApp def test_app_create() -> None: assert Application.get_app("my_app") is None app = Application("my_app") assert Application.get_app("my_app") is app # NOTE: for some strange reason, this test fails if I move this line # below the error assertion below... I don't know why. assert Application.get_or_create("my_app") is app with pytest.raises(ValueError, match="Application 'my_app' already exists"): Application("my_app") assert repr(app) == "Application('my_app')" Application.destroy("my_app") def test_app(full_app: FullApp) -> None: app = full_app app.commands.execute_command(app.Commands.OPEN) app.mocks.open.assert_called_once() app.commands.execute_command(app.Commands.COPY) app.mocks.copy.assert_called_once() app.commands.execute_command(app.Commands.PASTE) app.mocks.paste.assert_called_once() def test_sorting(full_app: FullApp) -> None: groups = list(full_app.menus.iter_menu_groups(full_app.Menus.EDIT)) assert len(groups) == 3 [g0, g1, g2] = groups assert all(i.group == "1_undo_redo" for i in g1) assert all(i.group == "2_copy_paste" for i in g2) assert [i.command.title for i in g1] == ["Undo", "Redo"] assert [i.command.title for i in g2] == ["Copy", "Paste"] def test_action_import_by_string(full_app: FullApp) -> None: """the REDO command is declared as a string in the conftest.py file This tests that it can be lazily imported at callback runtime and executed """ assert "fake_module" not in sys.modules assert full_app.commands.execute_command(full_app.Commands.REDO).result() assert "fake_module" in sys.modules full_app.mocks.redo.assert_called_once() # tests what happens when the module cannot be found with pytest.raises( ModuleNotFoundError, match="Command 'unimportable' was not importable" ): full_app.commands.execute_command(full_app.Commands.UNIMPORTABLE) # the second time we try within a session, nothing should happen full_app.commands.execute_command(full_app.Commands.UNIMPORTABLE) # tests what happens when the object is not callable cannot be found with pytest.raises( TypeError, match="Command 'not.callable' did not resolve to a callble object", ): full_app.commands.execute_command(full_app.Commands.NOT_CALLABLE) # the second time we try within a session, nothing should happen full_app.commands.execute_command(full_app.Commands.NOT_CALLABLE) def test_action_raises_exception(full_app: FullApp) -> None: result = full_app.commands.execute_command(full_app.Commands.RAISES) with pytest.raises(ValueError): result.result() # the function that raised the exception is `_raise_an_error` in conftest.py assert str(result.exception()) == "This is an error" assert not full_app.raise_synchronous_exceptions full_app.raise_synchronous_exceptions = True assert full_app.raise_synchronous_exceptions with pytest.raises(ValueError): full_app.commands.execute_command(full_app.Commands.RAISES) def test_app_context() -> None: app = Application("app1") assert isinstance(app.context, Context) Application.destroy("app1") assert app.context["is_windows"] == (os.name == "nt") assert "is_mac" in app.context assert "is_linux" in app.context app = Application("app2", context={"a": 1}) assert isinstance(app.context, Context) assert app.context["a"] == 1 Application.destroy("app2") app = Application("app3", context=Context({"a": 1})) assert isinstance(app.context, Context) assert app.context["a"] == 1 Application.destroy("app3") with pytest.raises(TypeError, match="context must be a Context or MutableMapping"): Application("app4", context=1) # type: ignore[arg-type] app-model-0.3.0/tests/test_command_registry.py000066400000000000000000000031241467241150700215200ustar00rootroot00000000000000import pytest from app_model.registries import CommandsRegistry, RegisteredCommand def raise_exc() -> None: raise RuntimeError("boom") def test_commands_registry() -> None: reg = CommandsRegistry() id1 = "my.id" reg.register_command(id1, lambda: 42, "My Title") assert "(1 commands)" in repr(reg) assert id1 in str(reg) assert id1 in reg with pytest.raises(KeyError, match="my.id2"): reg["my.id2"] with pytest.raises(ValueError, match="Command 'my.id' already registered"): reg.register_command(id1, lambda: 42, "My Title") assert reg.execute_command(id1, execute_asynchronously=True).result() == 42 assert reg.execute_command(id1, execute_asynchronously=False).result() == 42 reg.register_command("my.id2", raise_exc, "My Title 2") future_async = reg.execute_command("my.id2", execute_asynchronously=True) future_sync = reg.execute_command("my.id2", execute_asynchronously=False) with pytest.raises(RuntimeError, match="boom"): future_async.result() with pytest.raises(RuntimeError, match="boom"): future_sync.result() def test_commands_raises() -> None: reg = CommandsRegistry(raise_synchronous_exceptions=True) id_ = "my.id" title = "My Title" reg.register_command(id_, raise_exc, title) with pytest.raises(RuntimeError, match="boom"): reg.execute_command(id_) cmd = reg[id_] assert isinstance(cmd, RegisteredCommand) assert cmd.title == title with pytest.raises(AttributeError, match="immutable"): cmd.title = "New Title" assert cmd.title == title app-model-0.3.0/tests/test_context/000077500000000000000000000000001467241150700172645ustar00rootroot00000000000000app-model-0.3.0/tests/test_context/test_context.py000066400000000000000000000071441467241150700223670ustar00rootroot00000000000000import gc from unittest.mock import Mock import pytest from app_model.expressions import Context, create_context, get_context from app_model.expressions._context import _OBJ_TO_CONTEXT def test_create_context(): """You can create a context for any object""" class T: ... t = T() tid = id(t) ctx = create_context(t) assert get_context(t) == ctx assert hash(ctx) # hashable assert tid in _OBJ_TO_CONTEXT _OBJ_TO_CONTEXT.pop(tid) del t gc.collect() assert tid not in _OBJ_TO_CONTEXT # you can provide your own root, but it must be a context create_context(T(), root=Context()) with pytest.raises(AssertionError): create_context(T(), root={}) # type: ignore def test_create_and_get_scoped_contexts(): """Test that objects created in the stack of another contexted object. likely the most common way that this API will be used: """ before = len(_OBJ_TO_CONTEXT) class A: def __init__(self) -> None: create_context(self) self.b = B() class B: def __init__(self) -> None: create_context(self) obja = A() assert len(_OBJ_TO_CONTEXT) == before + 2 ctxa = get_context(obja) ctxb = get_context(obja.b) assert ctxa is not None assert ctxb is not None ctxa["hi"] = "hi" assert ctxb["hi"] == "hi" # keys get deleted on object deletion del obja gc.collect() assert len(_OBJ_TO_CONTEXT) == before def test_context_events(): """Changing context keys emits an event""" mock = Mock() root = Context() scoped = root.new_child() scoped.changed.connect(mock) # connect the mock to the child root["a"] = 1 # child re-emits parent events assert mock.call_args[0][0] == {"a"} mock.reset_mock() scoped["b"] = 1 # also emits own events assert mock.call_args[0][0] == {"b"} mock.reset_mock() del scoped["b"] assert mock.call_args[0][0] == {"b"} # but parent does not emit child events mock.reset_mock() mock2 = Mock() root.changed.connect(mock2) scoped["c"] = "c" mock.assert_called_once() mock2.assert_not_called() mock.reset_mock() with scoped.buffered_changes(): scoped["d"] = "d" scoped["e"] = "f" scoped["f"] = "f" mock.assert_called_once_with({"d", "e", "f"}) # check events properly propagated when adding already existing context as child mock3 = Mock() root2a = Context() root2b = Context() scoped2 = root2a.new_child(root2b) scoped2.changed.connect(mock3) # connect the mock to the child root2a["a"] = 1 # child re-emits parent events assert mock3.call_args[0][0] == {"a"} mock3.reset_mock() root2b["b"] = 1 # child re-emits added events assert mock3.call_args[0][0] == {"b"} mock3.reset_mock() scoped2["c"] = 1 # also emits own events assert mock3.call_args[0][0] == {"c"} # check events properly propagated when making a context of contexts mock4 = Mock() root3a = Context() root3b = Context() root3c = Context() root3d = Context() root3e = Context() combined = Context(root3a, root3b, root3c, root3d, root3e) combined.changed.connect(mock4) root3a["a"] = 1 assert mock4.call_args[0][0] == {"a"} mock4.reset_mock() root3b["b"] = 1 assert mock4.call_args[0][0] == {"b"} mock4.reset_mock() root3c["c"] = 1 assert mock4.call_args[0][0] == {"c"} mock4.reset_mock() root3d["d"] = 1 assert mock4.call_args[0][0] == {"d"} mock4.reset_mock() root3e["e"] = 1 assert mock4.call_args[0][0] == {"e"} app-model-0.3.0/tests/test_context/test_context_keys.py000066400000000000000000000037031467241150700234170ustar00rootroot00000000000000import pytest from app_model.expressions._context_keys import ( ContextKey, ContextKeyInfo, ContextNamespace, ) def test_context_key_info() -> None: key = ContextKey("default", "description", None, id="some_key") info = ContextKey.info() assert isinstance(info, list) and len(info) assert all(isinstance(x, ContextKeyInfo) for x in info) assert "some_key" in {x.key for x in info} assert repr(key) == "Expr.parse('some_key')" assert repr(key == 1) == "Expr.parse('some_key == 1')" def _adder(x: list) -> int: return sum(x) def test_context_namespace() -> None: class Ns(ContextNamespace): my_key = ContextKey[list, int](0, "description", _adder) optional_key = ContextKey[None, str](description="might be missing") assert "my_key" in Ns.__members__ assert str(Ns.my_key) == "my_key" assert any(x.description == "description" for x in ContextKey.info()) # make sure the type hints were inferred from adder assert Ns.my_key.__orig_class__.__args__ == (list, int) # type: ignore assert isinstance(Ns.my_key, ContextKey) ctx: dict = {} ns = Ns(ctx) assert ns.my_key == 0 assert ctx["my_key"] == 0 ns.my_key = 2 assert ctx["my_key"] == 2 assert "optional_key" not in ctx assert ns.optional_key is ContextKey.MISSING ns.reset("optional_key") # shouldn't raise error to reset a missing key # maybe the key is there though ctx["optional_key"] = "hi" assert ns.optional_key == "hi" ns.reset_all() assert ctx["my_key"] == 0 assert "optional_key" not in ctx assert repr(ns) == "{'my_key': 0, 'optional_key': MISSING}" assert Ns.my_key.eval(ctx) == 0 def test_good_naming() -> None: with pytest.raises(RuntimeError): # you're not allowed to create a key with an id different from # it's attribute name class Ns(ContextNamespace): my_key = ContextKey(id="not_my_key") # type: ignore app-model-0.3.0/tests/test_context/test_expressions.py000066400000000000000000000161441467241150700232650ustar00rootroot00000000000000import ast from copy import deepcopy import pytest from app_model.expressions import Constant, Expr, Name, parse_expression, safe_eval from app_model.expressions._expressions import _OPS, _iter_names def test_names(): assert Name("n").eval({"n": 5}) == 5 # currently, evaludating with a missing name is an error. with pytest.raises(NameError): Name("n").eval() assert repr(Name("n")) == "Expr.parse('n')" def test_constants(): assert Constant(1).eval() == 1 assert Constant(3.14).eval() == 3.14 assert Constant("asdf").eval() == "asdf" assert str(Constant("asdf")) == "'asdf'" assert str(Constant(r"asdf")) == "'asdf'" assert Constant(b"byte").eval() == b"byte" assert str(Constant(b"byte")) == "b'byte'" assert Constant(True).eval() is True assert Constant(False).eval() is False assert Constant(None).eval() is None assert repr(Constant(1)) == "Expr.parse('1')" # only {None, str, bytes, bool, int, float} allowed with pytest.raises(TypeError): Constant((1, 2)) # type: ignore def test_bool_ops(): n1 = Name[bool]("n1") true = Constant(True) false = Constant(False) assert (n1 & true).eval({"n1": True}) is True assert (n1 & false).eval({"n1": True}) is False assert (n1 & false).eval({"n1": False}) is False assert (n1 | true).eval({"n1": True}) is True assert (n1 | false).eval({"n1": True}) is True assert (n1 | false).eval({"n1": False}) is False # real constants assert (n1 & True).eval({"n1": True}) is True assert (n1 & False).eval({"n1": True}) is False assert (n1 & False).eval({"n1": False}) is False assert (n1 | True).eval({"n1": True}) is True assert (n1 | False).eval({"n1": True}) is True assert (n1 | False).eval({"n1": False}) is False # when working with Expr objects: # the binary "op" & refers to the boolean op "and" assert str(Constant(1) & 1) == "1 and 1" # note: using "and" does NOT work to combine expressions # (in this case, it would just return the second value "1") assert not isinstance(Constant(1) and 1, Expr) def test_bin_ops(): one = Constant(1) assert (one + 1).eval() == 2 assert (one - 1).eval() == 0 assert (one * 4).eval() == 4 assert (one / 4).eval() == 0.25 assert (one // 4).eval() == 0 assert (one % 2).eval() == 1 assert (one % 1).eval() == 0 assert (Constant(2) ** 2).eval() == 4 assert (one ^ 2).eval() == 3 assert (Constant(4) & Constant(16)).eval() == 16 assert (Constant(4) | Constant(16)).eval() == 4 assert (Constant(16).bitand(16)).eval() == 16 assert (Constant(16).bitor(4)).eval() == 20 def test_unary_ops(): assert Constant(1).eval() == 1 assert (+Constant(1)).eval() == 1 assert (-Constant(1)).eval() == -1 assert Constant(True).eval() is True assert (~Constant(True)).eval() is False def test_comparison(): n = Name[int]("n") n2 = Name[int]("n2") one = Constant(1) assert (n == n2).eval({"n": 2, "n2": 2}) assert not (n == n2).eval({"n": 2, "n2": 1}) assert (n != n2).eval({"n": 2, "n2": 1}) assert not (n != n2).eval({"n": 2, "n2": 2}) # real constant assert (n != 1).eval({"n": 2}) assert not (n != 2).eval({"n": 2}) assert (n < one).eval({"n": -1}) assert not (n < one).eval({"n": 2}) assert (n <= one).eval({"n": 0}) assert (n <= one).eval({"n": 1}) assert not (n <= one).eval({"n": 2}) # with real constant assert (n < 1).eval({"n": -1}) assert not (n < 1).eval({"n": 2}) assert (n <= 1).eval({"n": 0}) assert (n <= 1).eval({"n": 1}) assert not (n <= 1).eval({"n": 2}) assert (n > one).eval({"n": 2}) assert not (n > one).eval({"n": 1}) assert (n >= one).eval({"n": 2}) assert (n >= one).eval({"n": 1}) assert not (n >= one).eval({"n": 0}) # real constant assert (n > 1).eval({"n": 2}) assert not (n > 1).eval({"n": 1}) assert (n >= 1).eval({"n": 2}) assert (n >= 1).eval({"n": 1}) assert not (n >= 1).eval({"n": 0}) assert Expr.in_(Constant("a"), Constant("abcd")).eval() is True assert Constant("a").in_(Constant("abcd")).eval() is True assert Expr.not_in(Constant("a"), Constant("abcd")).eval() is False assert Constant("a").not_in(Constant("abcd")).eval() is False assert repr(n > n2) == "Expr.parse('n > n2')" def test_iter_names(): expr = "a if b in c else d > e" a = parse_expression(expr) assert a is parse_expression(a) b = Expr.parse(expr) # alias assert sorted(_iter_names(a)) == ["a", "b", "c", "d", "e"] assert sorted(_iter_names(b)) == ["a", "b", "c", "d", "e"] with pytest.raises(RuntimeError): # don't directly instantiate Expr() GOOD_EXPRESSIONS = [ "a and b", "a == 1", "a @ 1", "2 & 4", "a if b == 7 else False", # valid constants: "1", "3.14", "True", "1 in {1, 2, 3}", "1 in [1, 2, 3]", "1 in (1, 2, 3)", "False", "None", "hieee", "b'bytes'", "1 < x < 2", ] for k, v in _OPS.items(): if issubclass(k, ast.unaryop): GOOD_EXPRESSIONS.append(f"{v} 1" if v == "not" else f"{v}1") elif v not in {"is", "is not"}: GOOD_EXPRESSIONS.append(f"1 {v} 2") # these are not supported BAD_EXPRESSIONS = [ "a orr b", # typo "a b", # invalid syntax "a = b", # Assign "my.attribute", # Attribute "__import__(something)", # Call 'print("hi")', '{"key": "val"}', # dicts not yet supported "mylist[0]", # Index "mylist[0:1]", # Slice 'f"a"', # JoinedStr "a := 1", # NamedExpr r'f"{a}"', # FormattedValue "[v for v in val]", # ListComp "{v for v in val}", # SetComp r"{k:v for k, v in val}", # DictComp "(v for v in val)", # GeneratorExp ] @pytest.mark.parametrize("expr", GOOD_EXPRESSIONS) def test_serdes(expr): assert str(parse_expression(expr)) == expr assert repr(parse_expression(expr)) # smoke test @pytest.mark.parametrize("expr", BAD_EXPRESSIONS) def test_bad_serdes(expr): with pytest.raises(SyntaxError): parse_expression(expr) def test_deepcopy_expression(): deepcopy(parse_expression("1")) deepcopy(parse_expression("1 > 2")) deepcopy(parse_expression("1 & 2")) deepcopy(parse_expression("1 or 2")) deepcopy(parse_expression("not 1")) deepcopy(parse_expression("~x")) deepcopy(parse_expression("2 if x else 3")) def test_safe_eval(): expr = "7 > x if x > 2 else 3" assert safe_eval(expr, {"x": 3}) is True assert safe_eval(expr, {"x": 10}) is False assert safe_eval(expr, {"x": 1}) == 3 assert safe_eval(True) is True assert safe_eval(False) is False assert safe_eval("[1,2,3]") == [1, 2, 3] assert safe_eval("(1,2,3)") == (1, 2, 3) assert safe_eval("{1,2,3}") == {1, 2, 3} with pytest.raises(SyntaxError, match="Type 'Call' not supported"): safe_eval("func(x)") def test_eval_kwargs(): expr = parse_expression("a + b") assert expr.eval(a=1, b=2) == 3 assert expr.eval({"a": 2}, b=2) == 4 @pytest.mark.parametrize("expr", GOOD_EXPRESSIONS) def test_hash(expr): assert isinstance(hash(parse_expression(expr)), int) app-model-0.3.0/tests/test_key_codes.py000066400000000000000000000043111467241150700201160ustar00rootroot00000000000000from typing import Callable import pytest from app_model.types._constants import OperatingSystem from app_model.types._keys import KeyChord, KeyCode, KeyMod, ScanCode, SimpleKeyBinding from app_model.types._keys._key_codes import keycode_to_os_name, keycode_to_os_symbol def test_key_codes(): for key in KeyCode: assert key == KeyCode.from_string(str(key)) assert KeyCode.from_event_code(65) == KeyCode.KeyA assert KeyCode.validate(int(KeyCode.KeyA)) == KeyCode.KeyA assert KeyCode.validate(KeyCode.KeyA) == KeyCode.KeyA assert KeyCode.validate("A") == KeyCode.KeyA with pytest.raises(TypeError, match="cannot convert"): KeyCode.validate({"a"}) @pytest.mark.parametrize("symbol_or_name", ["symbol", "name"]) @pytest.mark.parametrize( ("os", "key_symbols_func", "key_names_func"), [ (OperatingSystem.WINDOWS, keycode_to_os_symbol, keycode_to_os_name), (OperatingSystem.MACOS, keycode_to_os_symbol, keycode_to_os_name), (OperatingSystem.LINUX, keycode_to_os_symbol, keycode_to_os_name), ], ) def test_key_codes_to_os( symbol_or_name: str, os: OperatingSystem, key_symbols_func: Callable[[KeyCode, OperatingSystem], str], key_names_func: Callable[[KeyCode, OperatingSystem], str], ) -> None: os_method = f"os_{symbol_or_name}" key_map_func = key_symbols_func if symbol_or_name == "symbol" else key_names_func for key in KeyCode: assert getattr(key, os_method)(os) == key_map_func(key, os) def test_scan_codes(): for scan in ScanCode: assert scan == ScanCode.from_string(str(scan)), scan def test_key_combo(): """KeyCombo is an integer combination of one or more KeyMod and KeyCode.""" combo = KeyMod.Shift | KeyMod.Alt | KeyCode.KeyK assert repr(combo) == "" kb = SimpleKeyBinding.from_int(combo) assert kb == SimpleKeyBinding(shift=True, alt=True, key=KeyCode.KeyK) def test_key_chord(): """KeyChord is an integer combination of two KeyCombos, KeyCodes, or integers.""" chord = KeyChord(KeyMod.CtrlCmd | KeyCode.KeyK, KeyCode.KeyM) assert int(chord) == 1968156 assert repr(chord) == "KeyChord(, )" app-model-0.3.0/tests/test_keybindings.py000066400000000000000000000136221467241150700204640ustar00rootroot00000000000000import itertools import sys from typing import ClassVar import pytest from pydantic_compat import PYDANTIC2, BaseModel from app_model.types import ( KeyBinding, KeyBindingRule, KeyCode, KeyMod, SimpleKeyBinding, ) from app_model.types._constants import OperatingSystem from app_model.types._keys import KeyChord, KeyCombo, StandardKeyBinding MAC = sys.platform == "darwin" @pytest.mark.parametrize("use_symbols", [True, False]) @pytest.mark.parametrize( ("os", "joinchar", "expected_use_symbols", "expected_non_use_symbols"), [ (OperatingSystem.WINDOWS, "+", "⊞+A", "Win+A"), (OperatingSystem.LINUX, "-", "Super-A", "Super-A"), (OperatingSystem.MACOS, "", "⌘A", "CmdA"), ], ) def test_simple_keybinding_to_text( use_symbols: bool, os: OperatingSystem, joinchar: str, expected_use_symbols: str, expected_non_use_symbols: str, ) -> None: kb = SimpleKeyBinding.from_str("Meta+A") expected = expected_non_use_symbols if use_symbols: expected = expected_use_symbols assert kb.to_text(os=os, use_symbols=use_symbols, joinchar=joinchar) == expected @pytest.mark.parametrize("use_symbols", [True, False]) @pytest.mark.parametrize( ("os", "joinchar", "expected_use_symbols", "expected_non_use_symbols"), [ ( OperatingSystem.WINDOWS, "+", "Ctrl+A ⇧+[ Alt+/ ⊞+9", "Ctrl+A Shift+[ Alt+/ Win+9", ), ( OperatingSystem.LINUX, "-", "Ctrl-A ⇧-[ Alt-/ Super-9", "Ctrl-A Shift-[ Alt-/ Super-9", ), (OperatingSystem.MACOS, "", "⌃A ⇧[ ⌥/ ⌘9", "ControlA Shift[ Option/ Cmd9"), ], ) def test_keybinding_to_text( use_symbols: bool, os: OperatingSystem, joinchar: str, expected_use_symbols: str, expected_non_use_symbols: str, ) -> None: kb = KeyBinding.from_str("Ctrl+A Shift+[ Alt+/ Meta+9") expected = expected_non_use_symbols if use_symbols: expected = expected_use_symbols assert kb.to_text(os=os, use_symbols=use_symbols, joinchar=joinchar) == expected @pytest.mark.parametrize("key", list("ADgf`]/,")) @pytest.mark.parametrize("mod", ["ctrl", "shift", "alt", "meta", None]) def test_simple_keybinding_single_mod(mod: str, key: str) -> None: _mod = f"{mod}+" if mod else "" kb = SimpleKeyBinding.from_str(f"{_mod}{key}") assert str(kb).lower() == f"{_mod}{key}".lower() assert not kb.is_modifier_key() # we can compare it with another SimpleKeyBinding # using validate method just for test coverage... will pass to from_str assert kb == SimpleKeyBinding._parse_input(f"{_mod}{key}") # or with a string assert kb == f"{_mod}{key}" assert kb != ["A", "B"] # check type error during comparison # round trip to int assert isinstance(kb.to_int(), KeyCombo) # using validate method just for test coverage... will pass to from_int assert SimpleKeyBinding._parse_input(int(kb)) == kb assert SimpleKeyBinding._parse_input(kb) == kb # first part of a Keybinding is a simple keybinding as_full_kb = KeyBinding.validate(kb) assert as_full_kb.part0 == kb assert KeyBinding.validate(int(kb)).part0 == kb assert int(as_full_kb) == int(kb) def test_simple_keybinding_multi_mod() -> None: # here we're also testing that cmd and win get cast to 'KeyMod.CtrlCmd' kb = SimpleKeyBinding.from_str("cmd+shift+A") assert not kb.is_modifier_key() assert int(kb) & KeyMod.CtrlCmd | KeyMod.Shift kb = SimpleKeyBinding.from_str("win+shift+A") assert not kb.is_modifier_key() assert int(kb) & KeyMod.CtrlCmd | KeyMod.Shift kb = SimpleKeyBinding.from_str("win") # just a modifier assert kb.is_modifier_key() controls = ["ctrl", "control", "ctl", "⌃", "^"] shifts = ["shift", "⇧"] alts = ["alt", "opt", "option", "⌥"] metas = ["meta", "super", "cmd", "command", "⌘", "win", "windows", "⊞"] delimiters = ["+", "-"] key = ["A"] combos = [ delim.join(x) for delim, *x in itertools.product(delimiters, controls, shifts, alts, metas, key) ] @pytest.mark.parametrize("key", combos) def test_keybinding_parser(key: str) -> None: # Test all the different ways to write the modifiers assert str(KeyBinding.from_str(key)) == "Ctrl+Shift+Alt+Meta+A" def test_chord_keybinding() -> None: kb = KeyBinding.from_str("Shift+A Cmd+9") assert len(kb) == 2 assert kb != "Shift+A Cmd+9" # comparison with string considered anti-pattern assert kb == KeyBinding.from_str("Shift+A Cmd+9") assert kb.part0 == SimpleKeyBinding(shift=True, key=KeyCode.KeyA) assert kb.part0 == "Shift+A" assert str(kb) in repr(kb) # round trip to int assert isinstance(kb.to_int(), KeyChord) # using validate method just for test coverage... will pass to from_int assert KeyBinding.validate(int(kb)) == kb assert KeyBinding.validate(kb) == kb def test_in_dict() -> None: a = SimpleKeyBinding.from_str("Shift+A") b = KeyBinding.from_str("Shift+B") try: kbs = { a: 0, b: 1, } except TypeError as e: if str(e).startswith("unhashable type"): pytest.fail(f"keybinds not hashable: {e}") else: raise e assert kbs[a] == 0 assert kbs[b] == 1 new_a = KeyBinding.from_str("Shift+A") with pytest.raises(KeyError): kbs[new_a] def test_in_model() -> None: class M(BaseModel): key: KeyBinding if not PYDANTIC2: class Config: json_encoders: ClassVar[dict] = {KeyBinding: str} m = M(key="Shift+A B") # pydantic v1 and v2 have slightly different json outputs assert m.model_dump_json().replace('": "', '":"') == '{"key":"Shift+A B"}' def test_standard_keybindings() -> None: class M(BaseModel): key: KeyBindingRule m = M(key=StandardKeyBinding.Copy) assert m.key.primary == KeyMod.CtrlCmd | KeyCode.KeyC app-model-0.3.0/tests/test_qt/000077500000000000000000000000001467241150700162245ustar00rootroot00000000000000app-model-0.3.0/tests/test_qt/__init__.py000066400000000000000000000001721467241150700203350ustar00rootroot00000000000000import pytest try: import qtpy # noqa except ImportError: pytest.skip("No Qt backend", allow_module_level=True) app-model-0.3.0/tests/test_qt/test_demos.py000066400000000000000000000006221467241150700207440ustar00rootroot00000000000000import runpy from pathlib import Path import pytest from qtpy.QtWidgets import QApplication DEMO = Path(__file__).parent.parent.parent / "demo" @pytest.mark.parametrize("fname", ["qapplication.py", "model_app.py", "multi_file"]) def test_qapp(qapp, fname, monkeypatch): monkeypatch.setattr(QApplication, "exec_", lambda *a, **k: None) runpy.run_path(str(DEMO / fname), run_name="__main__") app-model-0.3.0/tests/test_qt/test_qactions.py000066400000000000000000000066261467241150700214700ustar00rootroot00000000000000from typing import TYPE_CHECKING from unittest.mock import Mock import pytest from app_model.backends.qt import QCommandRuleAction, QMenuItemAction from app_model.types import ( Action, CommandRule, KeyBindingRule, KeyCode, MenuItem, ToggleRule, ) if TYPE_CHECKING: from app_model import Application from conftest import FullApp def test_cache_qaction(qapp, full_app: "FullApp") -> None: action = next( i for k, items in full_app.menus for i in items if isinstance(i, MenuItem) ) a1 = QMenuItemAction.create(action, full_app) a2 = QMenuItemAction.create(action, full_app) assert a1 is a2 assert repr(a1).startswith("QMenuItemAction") def test_toggle_qaction(qapp, simple_app: "Application") -> None: mock = Mock() x = False def current() -> bool: mock() return x def _toggle() -> None: nonlocal x x = not x action = Action( id="test.toggle", title="Test toggle", toggled=ToggleRule(get_current=current), callback=_toggle, ) simple_app.register_action(action) a1 = QCommandRuleAction(action, simple_app) mock.assert_called_once() mock.reset_mock() assert a1.isCheckable() assert not a1.isChecked() a1.trigger() assert a1.isChecked() assert x a1.trigger() assert not a1.isChecked() assert not x x = True a1._refresh() mock.assert_called_once() assert a1.isChecked() def test_icon_visible_in_menu(qapp, simple_app: "Application") -> None: rule = CommandRule(id="test", title="Test", icon_visible_in_menu=False) q_action = QCommandRuleAction(command_rule=rule, app=simple_app) assert not q_action.isIconVisibleInMenu() @pytest.mark.parametrize( ("tooltip", "expected_tooltip"), [ ("", "Test tooltip"), ("Test action with a tooltip", "Test action with a tooltip"), ], ) def test_tooltip( qapp, simple_app: "Application", tooltip: str, expected_tooltip: str ) -> None: action = Action( id="test.tooltip", title="Test tooltip", tooltip=tooltip, callback=lambda: None ) simple_app.register_action(action) q_action = QCommandRuleAction(action, simple_app) assert q_action.toolTip() == expected_tooltip @pytest.mark.parametrize( ("tooltip", "tooltip_with_keybinding", "tooltip_without_keybinding"), [ ("", "Test keybinding tooltip (K)", "Test keybinding tooltip"), ( "Test action with a tooltip and a keybinding", "Test action with a tooltip and a keybinding (K)", "Test action with a tooltip and a keybinding", ), ], ) def test_keybinding_in_tooltip( qapp, simple_app: "Application", tooltip: str, tooltip_with_keybinding: str, tooltip_without_keybinding: str, ) -> None: action = Action( id="test.keybinding.tooltip", title="Test keybinding tooltip", callback=lambda: None, tooltip=tooltip, keybindings=[KeyBindingRule(primary=KeyCode.KeyK)], ) simple_app.register_action(action) # check initial action instance shows keybinding info in its tooltip if available q_action = QCommandRuleAction(action, simple_app) assert q_action.toolTip() == tooltip_with_keybinding # check setting tooltip manually removes keybinding info q_action.setToolTip(tooltip) assert q_action.toolTip() == tooltip_without_keybinding app-model-0.3.0/tests/test_qt/test_qkeybindingedit.py000066400000000000000000000006321467241150700230100ustar00rootroot00000000000000from qtpy.QtGui import QKeySequence from app_model.backends.qt import QModelKeyBindingEdit from app_model.types import KeyBinding, KeyCode, KeyMod def test_qkeysequenceedit(qtbot): edit = QModelKeyBindingEdit() qtbot.addWidget(edit) assert edit.keyBinding() is None edit.setKeySequence(QKeySequence("Shift+A")) assert edit.keyBinding() == KeyBinding(parts=[KeyMod.Shift | KeyCode.KeyA]) app-model-0.3.0/tests/test_qt/test_qkeymap.py000066400000000000000000000155351467241150700213150ustar00rootroot00000000000000from unittest.mock import patch from qtpy.QtCore import Qt from qtpy.QtGui import QKeySequence from app_model.backends.qt import ( _qkeymap, qkey2modelkey, qkeysequence2modelkeybinding, qmods2modelmods, ) from app_model.backends.qt._qkeymap import modelkey2qkey from app_model.types import KeyBinding, KeyCode, KeyCombo, KeyMod # stuff we don't know how to deal with yet def test_modelkey_lookup() -> None: assert modelkey2qkey(KeyCode.KeyM) == Qt.Key.Key_M with patch.object(_qkeymap, "MAC", True): with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=False): assert modelkey2qkey(KeyCode.Ctrl) == Qt.Key.Key_Control assert modelkey2qkey(KeyCode.Meta) == Qt.Key.Key_Meta with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=True): assert modelkey2qkey(KeyCode.Meta) == Qt.Key.Key_Control assert modelkey2qkey(KeyCode.Ctrl) == Qt.Key.Key_Meta with patch.object(_qkeymap, "MAC", False): assert modelkey2qkey(KeyCode.Ctrl) == Qt.Key.Key_Control assert modelkey2qkey(KeyCode.Meta) == Qt.Key.Key_Meta def test_qkey_lookup() -> None: for keyname in (k for k in dir(Qt.Key) if k.startswith("Key")): key = getattr(Qt.Key, keyname) assert isinstance(qkey2modelkey(key), (KeyCode, KeyCombo)) assert qkey2modelkey(Qt.Key.Key_M) == KeyCode.KeyM with patch.object(_qkeymap, "MAC", True): with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=False): assert qkey2modelkey(Qt.Key.Key_Control) == KeyCode.Ctrl assert qkey2modelkey(Qt.Key.Key_Meta) == KeyCode.Meta with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=True): assert qkey2modelkey(Qt.Key.Key_Control) == KeyCode.Meta assert qkey2modelkey(Qt.Key.Key_Meta) == KeyCode.Ctrl with patch.object(_qkeymap, "MAC", False): assert qkey2modelkey(Qt.Key.Key_Control) == KeyCode.Ctrl assert qkey2modelkey(Qt.Key.Key_Meta) == KeyCode.Meta def test_qmod_lookup() -> None: assert qmods2modelmods(Qt.KeyboardModifier.ShiftModifier) == KeyMod.Shift assert qmods2modelmods(Qt.KeyboardModifier.AltModifier) == KeyMod.Alt with patch.object(_qkeymap, "MAC", True): with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=False): assert ( qmods2modelmods(Qt.KeyboardModifier.ControlModifier) == KeyMod.WinCtrl ) assert qmods2modelmods(Qt.KeyboardModifier.MetaModifier) == KeyMod.CtrlCmd with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=True): assert ( qmods2modelmods(Qt.KeyboardModifier.ControlModifier) == KeyMod.CtrlCmd ) assert qmods2modelmods(Qt.KeyboardModifier.MetaModifier) == KeyMod.WinCtrl with patch.object(_qkeymap, "MAC", False): assert qmods2modelmods(Qt.KeyboardModifier.ControlModifier) == KeyMod.CtrlCmd assert qmods2modelmods(Qt.KeyboardModifier.MetaModifier) == KeyMod.WinCtrl def test_qkeysequence2modelkeybinding() -> None: seq = QKeySequence( Qt.Modifier.SHIFT | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K ) app_key = KeyBinding(parts=[KeyMod.Shift | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key seq = QKeySequence( Qt.Modifier.ALT | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K ) app_key = KeyBinding(parts=[KeyMod.Alt | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key with patch.object(_qkeymap, "MAC", True): with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=False): # on Macs, unswapped, Meta -> Cmd seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.CtrlCmd | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key # on Macs, unswapped, Ctrl -> Ctrl seq = QKeySequence( Qt.Modifier.CTRL | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.WinCtrl | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_Meta, Qt.Modifier.CTRL | Qt.Key.Key_Control, ) app_key = KeyBinding( parts=[KeyMod.CtrlCmd | KeyCode.Meta, KeyMod.WinCtrl | KeyCode.Ctrl] ) assert qkeysequence2modelkeybinding(seq) == app_key with patch.object(_qkeymap, "_mac_ctrl_meta_swapped", return_value=True): # on Mac swapped, Ctrl -> Meta/Cmd seq = QKeySequence( Qt.Modifier.CTRL | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.CtrlCmd | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key # on Mac swapped, Meta/Cmd -> Ctrl seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.WinCtrl | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_Meta, Qt.Modifier.CTRL | Qt.Key.Key_Control, ) app_key = KeyBinding( parts=[KeyMod.WinCtrl | KeyCode.Ctrl, KeyMod.CtrlCmd | KeyCode.Meta] ) assert qkeysequence2modelkeybinding(seq) == app_key with patch.object(_qkeymap, "MAC", False): # on Win/Unix, Ctrl -> Ctrl seq = QKeySequence( Qt.Modifier.CTRL | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.CtrlCmd | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key # on Win, Meta -> Win, on Unix, Meta -> Super seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_M, Qt.KeyboardModifier.NoModifier | Qt.Key.Key_K, ) app_key = KeyBinding(parts=[KeyMod.WinCtrl | KeyCode.KeyM, KeyCode.KeyK]) assert qkeysequence2modelkeybinding(seq) == app_key seq = QKeySequence( Qt.Modifier.META | Qt.Key.Key_Meta, Qt.Modifier.CTRL | Qt.Key.Key_Control, ) app_key = KeyBinding( parts=[KeyMod.WinCtrl | KeyCode.Meta, KeyMod.CtrlCmd | KeyCode.Ctrl] ) assert qkeysequence2modelkeybinding(seq) == app_key app-model-0.3.0/tests/test_qt/test_qmainwindow.py000066400000000000000000000015051467241150700221730ustar00rootroot00000000000000from typing import TYPE_CHECKING from qtpy.QtCore import Qt from app_model.backends.qt import QModelMainWindow, QModelToolBar if TYPE_CHECKING: from ..conftest import FullApp # noqa: TID252 def test_qmodel_main_window(qtbot, full_app: "FullApp"): win = QModelMainWindow(full_app) qtbot.addWidget(win) win.setModelMenuBar( { full_app.Menus.FILE: "File", full_app.Menus.EDIT: "Edit", full_app.Menus.HELP: "Help", } ) assert [a.text() for a in win.menuBar().actions()] == ["File", "Edit", "Help"] tb = win.addModelToolBar( full_app.Menus.FILE, toolbutton_style=Qt.ToolButtonStyle.ToolButtonTextBesideIcon, ) assert isinstance(tb, QModelToolBar) win.addModelToolBar(full_app.Menus.EDIT, area=Qt.ToolBarArea.RightToolBarArea) app-model-0.3.0/tests/test_qt/test_qmenu.py000066400000000000000000000127161467241150700207710ustar00rootroot00000000000000from __future__ import annotations import sys from typing import TYPE_CHECKING, cast import pytest from qtpy.QtCore import Qt from qtpy.QtWidgets import QAction, QMainWindow from app_model.backends.qt import QModelMenu, QModelToolBar if TYPE_CHECKING: from pytestqt.plugin import QtBot from conftest import FullApp SEP = "" LINUX = sys.platform.startswith("linux") @pytest.mark.parametrize("MenuCls", [QModelMenu, QModelToolBar]) def test_menu( MenuCls: type[QModelMenu] | type[QModelToolBar], qtbot: QtBot, full_app: FullApp ) -> None: app = full_app menu = MenuCls(app.Menus.EDIT, app, title="just-for-coverage") qtbot.addWidget(menu) # The "" are separators, according to our group settings in full_app menu_texts = [a.text() for a in menu.actions()] assert menu_texts == ["AtTop", SEP, "Undo", "Redo", SEP, "Copy", "Paste"] # check that triggering the actions calls the associated commands for cmd in (app.Commands.UNDO, app.Commands.REDO): action = cast(QAction, menu.findAction(cmd)) with qtbot.waitSignal(action.triggered): action.trigger() getattr(app.mocks, cmd).assert_called_once() redo_action = cast(QAction, menu.findAction(app.Commands.REDO)) assert redo_action.isVisible() assert redo_action.isEnabled() # change that visibility and enablement follows the context menu.update_from_context({"allow_undo_redo": True, "something_to_undo": False}) assert redo_action.isVisible() assert redo_action.isEnabled() menu.update_from_context({"allow_undo_redo": False, "something_to_undo": False}) assert redo_action.isVisible() assert not redo_action.isEnabled() menu.update_from_context({"allow_undo_redo": False, "something_to_undo": True}) assert not redo_action.isVisible() assert not redo_action.isEnabled() menu.update_from_context({"allow_undo_redo": True, "something_to_undo": False}) assert redo_action.isVisible() assert redo_action.isEnabled() # useful error when we forget a required name with pytest.raises(NameError, match="Names required to eval this expression"): menu.update_from_context({}) menu._disconnect() def test_submenu(qtbot: QtBot, full_app: FullApp) -> None: app = full_app menu = QModelMenu(app.Menus.FILE, app) qtbot.addWidget(menu) menu_texts = [a.text() for a in menu.actions()] assert menu_texts == ["Open From...", "Open..."] submenu = menu.findChild(QModelMenu, app.Menus.FILE_OPEN_FROM) assert isinstance(submenu, QModelMenu) submenu.setVisible(True) assert submenu.isVisible() assert submenu.isEnabled() menu.aboutToShow.emit() # for test coverage # "not something_open" is the when clause # "friday" is the enablement clause menu.update_from_context({"something_open": False, "friday": True}) assert submenu.isVisible() assert submenu.isEnabled() menu.update_from_context({"something_open": False, "friday": False}) assert submenu.isVisible() assert not submenu.isEnabled() menu.update_from_context({"something_open": True, "friday": False}) # assert not submenu.isVisible() assert not submenu.isEnabled() menu.update_from_context({"something_open": True, "friday": True}) # assert not submenu.isVisible() assert submenu.isEnabled() @pytest.mark.filterwarnings("ignore:QPixmapCache.find:") @pytest.mark.skipif(LINUX, reason="Linux keytest not working") def test_shortcuts(qtbot: QtBot, full_app: FullApp) -> None: app = full_app win = QMainWindow() menu = QModelMenu(app.Menus.EDIT, app=app, title="Edit", parent=win) win.menuBar().addMenu(menu) qtbot.addWidget(win) qtbot.addWidget(menu) with qtbot.waitExposed(win): win.show() copy_action = menu.findAction(app.Commands.COPY) with qtbot.waitSignal(copy_action.triggered, timeout=2000): qtbot.keyClicks(win, "C", Qt.KeyboardModifier.ControlModifier) paste_action = menu.findAction(app.Commands.PASTE) with qtbot.waitSignal(paste_action.triggered, timeout=1000): qtbot.keyClicks(win, "V", Qt.KeyboardModifier.ControlModifier) def test_toggled_menu_item(qtbot: QtBot, full_app: FullApp) -> None: app = full_app menu = QModelMenu(app.Menus.HELP, app) qtbot.addWidget(menu) menu.update_from_context({"thing_toggled": True}) action = menu.findAction(app.Commands.TOGGLE_THING) assert action.isChecked() menu.update_from_context({"thing_toggled": False}) assert not action.isChecked() @pytest.mark.parametrize("MenuCls", [QModelMenu, QModelToolBar]) def test_menu_events( MenuCls: type[QModelMenu] | type[QModelToolBar], qtbot: QtBot, full_app: FullApp ) -> None: app = full_app menu = MenuCls(app.Menus.EDIT, app) qtbot.addWidget(menu) # The "" are separators, according to our group settings in full_app menu_texts = [a.text() for a in menu.actions()] assert menu_texts == ["AtTop", SEP, "Undo", "Redo", SEP, "Copy", "Paste"] # simulate something changing the edit menu... normally this would be # triggered by a dispose() call, but that's a bit hard to do currently with the # test app fixture. copy_item = next( x for x in full_app.menus._menu_items["edit"] if x.command.title == "Copy" ) full_app.menus._menu_items["edit"].pop(copy_item) full_app.menus.menus_changed.emit(app.Menus.EDIT) menu_texts = [a.text() for a in menu.actions()] assert menu_texts == ["AtTop", SEP, "Undo", "Redo", SEP, "Paste"] app-model-0.3.0/tests/test_registries.py000066400000000000000000000053401467241150700203340ustar00rootroot00000000000000import pytest from app_model.registries import KeyBindingsRegistry, MenusRegistry from app_model.types import ( Action, KeyBinding, KeyBindingRule, KeyCode, KeyMod, MenuItem, ) def test_menus_registry() -> None: reg = MenusRegistry() reg.append_menu_items([("file", {"command": {"id": "file.new", "title": "File"}})]) reg.append_menu_items([("file.sub", {"submenu": "Sub", "title": "SubTitle"})]) assert isinstance(reg.get_menu("file")[0], MenuItem) assert "(2 menus)" in repr(reg) assert "File" in str(reg) assert "Sub" in str(reg) # ok to change def test_keybindings_registry() -> None: reg = KeyBindingsRegistry() assert "(0 bindings)" in repr(reg) def test_register_keybinding_rule_filter_type() -> None: """Check `_filter_keybinding` type checking when setting.""" reg = KeyBindingsRegistry() with pytest.raises(TypeError, match="'filter_keybinding' must be a callable"): reg.filter_keybinding = "string" def _filter_fun(kb: KeyBinding) -> str: if kb.part0.is_modifier_key(): return "modifier only sequences not allowed" return "" def test_register_keybinding_rule_filter_get() -> None: """Check `_filter_keybinding` getter.""" reg = KeyBindingsRegistry() reg.filter_keybinding = _filter_fun assert callable(reg.filter_keybinding) def test_register_keybinding_rule_filter() -> None: """Check `filter_keybinding` in `register_keybinding_rule`.""" reg = KeyBindingsRegistry() reg.filter_keybinding = _filter_fun # Valid keybinding kb = KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyO) reg.register_keybinding_rule("test", kb) # Invalid keybinding kb = KeyBindingRule(primary=KeyMod.Alt) with pytest.raises(ValueError, match=r"Alt\+: modifier only"): reg.register_keybinding_rule("test", kb) @pytest.mark.parametrize( "kb, msg", [ ( [ {"primary": KeyMod.CtrlCmd | KeyCode.KeyA}, {"primary": KeyMod.Shift | KeyCode.KeyC}, ], "", ), ( [{"primary": KeyMod.Alt}, {"primary": KeyMod.Shift}], r"Alt\+: modifier only sequences not allowed\nShift\+: modifier", ), ], ) def test_register_action_keybindings_filter(kb, msg) -> None: """Check `filter_keybinding` in `register_action_keybindings`.""" reg = KeyBindingsRegistry() reg.filter_keybinding = _filter_fun action = Action( id="cmd_id1", title="title1", callback=lambda: None, keybindings=kb, ) if msg: with pytest.raises(ValueError, match=msg): reg.register_action_keybindings(action) else: reg.register_action_keybindings(action) app-model-0.3.0/tests/test_types.py000066400000000000000000000012311467241150700173130ustar00rootroot00000000000000import pytest from pydantic import ValidationError from app_model.types import Action, Icon def test_icon_validate(): assert Icon._validate('"fa6s.arrow_down"') == Icon( dark='"fa6s.arrow_down"', light='"fa6s.arrow_down"' ) def test_action_validation(): with pytest.raises(ValidationError, match="'s!adf' is not a valid python_name"): Action(id="test", title="test", callback="s!adf") with pytest.raises(ValidationError): Action(id="test", title="test", callback=[]) with pytest.raises(ValidationError, match="'x.:asdf' is not a valid"): Action(id="test", title="test", callback="x.:asdf")