app_model-0.2.0/.github_changelog_generator0000644000000000000000000000042113615410400015726 0ustar00user=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.2.0/.pre-commit-config.yaml0000644000000000000000000000237113615410400014655 0ustar00ci: autoupdate_schedule: monthly autofix_commit_msg: "style: [pre-commit.ci] auto fixes [...]" autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate" default_install_hook_types: [pre-commit, commit-msg] repos: - repo: https://github.com/compilerla/conventional-pre-commit rev: v2.3.0 hooks: - id: conventional-pre-commit stages: [commit-msg] - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: - id: check-docstring-first - id: end-of-file-fixer - id: trailing-whitespace - repo: https://github.com/psf/black rev: 23.7.0 hooks: - id: black - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.0.278 hooks: - id: ruff args: ["--fix"] - repo: https://github.com/abravalheri/validate-pyproject rev: v0.13 hooks: - id: validate-pyproject - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.4.1 hooks: - id: mypy files: "^src/" additional_dependencies: - pydantic - in-n-out # manual hooks - repo: https://github.com/codespell-project/codespell rev: v2.2.5 hooks: - id: codespell exclude: CHANGELOG.md stages: - "manual" app_model-0.2.0/.readthedocs.yaml0000644000000000000000000000045013615410400013617 0ustar00# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details version: 2 build: os: ubuntu-20.04 tools: python: "3.9" mkdocs: configuration: mkdocs.yml fail_on_warning: true python: install: - method: pip path: . extra_requirements: - docs app_model-0.2.0/CHANGELOG.md0000644000000000000000000002751013615410400012207 0ustar00# Changelog ## [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.2.0/codecov.yml0000644000000000000000000000010313615410400012530 0ustar00coverage: status: patch: default: target: 100% app_model-0.2.0/mkdocs.yml0000644000000000000000000000216713615410400012402 0ustar00site_name: App Model site_url: https://github.com/pyapp-kit/app-model site_author: Talley Lambert site_description: Generic application schema implemented in python. repo_name: pyapp-kit/app-model repo_url: https://github.com/pyapp-kit/app-model copyright: Copyright © 2021 - 2022 Talley Lambert watch: - src plugins: - search - autorefs - minify: minify_html: true - macros: module_name: docs/_macros - mkdocstrings: handlers: python: import: - https://docs.python.org/3/objects.inv options: docstring_style: numpy show_bases: false merge_init_into_class: yes show_source: no show_root_full_path: no show_root_heading: yes docstring_section_style: list markdown_extensions: - tables - pymdownx.inlinehilite - pymdownx.snippets - pymdownx.superfences - pymdownx.details - admonition theme: name: material icon: repo: material/github logo: material/application-cog-outline features: - navigation.instant - search.highlight - search.suggest app_model-0.2.0/setup.py0000644000000000000000000000123213615410400012101 0ustar00import sys sys.stderr.write( """ =============================== Unsupported installation method =============================== app-model does not support installation with `python setup.py install`. Please use `python -m pip install .` instead. """ ) sys.exit(1) # The below code will never execute, however GitHub is particularly # picky about where it finds Python packaging metadata. # See: https://github.com/github/feedback/discussions/6456 # # To be removed once GitHub catches up. setup( name="app-model", install_requires=[ "psygnal>=0.3.4", "pydantic>=1.8", "in-n-out>=0.1.5", "typing_extensions", ], ) app_model-0.2.0/.github/ISSUE_TEMPLATE.md0000644000000000000000000000050013615410400014431 0ustar00* 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.2.0/.github/TEST_FAIL_TEMPLATE.md0000644000000000000000000000061113615410400015116 0ustar00--- 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.2.0/.github/dependabot.yml0000644000000000000000000000042413615410400014561 0ustar00# 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.2.0/.github/workflows/ci.yml0000644000000000000000000001150513615410400015106 0ustar00name: CI on: push: branches: - main tags: - "v*" pull_request: {} workflow_dispatch: jobs: check-manifest: name: Check Manifest runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: "3.x" - run: pip install check-manifest && check-manifest test: name: ${{ matrix.platform }} (${{ matrix.python-version }}) runs-on: ${{ matrix.platform }} strategy: fail-fast: false matrix: python-version: ["3.8", "3.10", "3.11"] platform: [ubuntu-latest, macos-latest, windows-latest] steps: - name: Cancel Previous Runs uses: styfle/cancel-workflow-action@0.11.0 with: access_token: ${{ github.token }} - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install -U pip python -m pip install -e .[test] - name: Test run: pytest -s --color=yes test-pydantic1: name: pydantic1 (py${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.8", "3.10", "3.11"] steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install -U pip python -m pip install -e .[test] pip install 'pydantic<2' - name: Test run: pytest --color=yes --cov=app_model --cov-report=xml - name: Coverage uses: codecov/codecov-action@v3 test-qt: name: ${{ matrix.platform }} ${{ matrix.qt-backend }} (${{ matrix.python-version }}) runs-on: ${{ matrix.platform }} strategy: fail-fast: false matrix: python-version: ["3.10"] platform: [macos-latest, ubuntu-latest, windows-latest] qt-backend: [PyQt5, PyQt6, PySide2, "'PySide6<6.5.1'"] include: - python-version: "3.8" platform: "ubuntu-latest" qt-backend: "PyQt5==5.12" steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - uses: tlambert03/setup-qt-libs@v1 - name: Install dependencies run: | python -m pip install -U pip python -m pip install -e .[qt,test,test-qt] python -m pip install ${{ matrix.qt-backend }} - name: Test uses: aganders3/headless-gui@v1 with: run: python -m pytest -s --cov=app_model --cov-report=xml --cov-report=term-missing --color=yes - name: Coverage uses: codecov/codecov-action@v3 test_napari: name: napari (${{ matrix.napari-version }}, ${{ matrix.qt-backend }}) runs-on: ubuntu-latest strategy: matrix: napari-version: [""] # "" is HEAD qt-backend: [pyqt5, pyside2] steps: - uses: actions/checkout@v3 with: path: app-model - uses: actions/checkout@v3 with: repository: napari/napari path: napari fetch-depth: 0 ref: ${{ matrix.napari-version }} - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.10" - uses: tlambert03/setup-qt-libs@v1 - name: Install dependencies run: | python -m pip install -U pip python -m pip install -e app-model python -m pip install -e napari[testing,${{ matrix.qt-backend }}] - name: Test uses: aganders3/headless-gui@v1 with: working-directory: napari run: python -m pytest napari/_qt napari/_app_model --color=yes -x deploy: name: Deploy needs: [check-manifest, test, test-qt, test_napari] if: "success() && startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - name: Set up Python uses: actions/setup-python@v4 with: python-version: "3.x" - name: install run: | git tag pip install -U pip pip install -U build twine python -m build twine check dist/* ls -lh dist - name: Build and publish run: twine upload dist/* env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} - uses: softprops/action-gh-release@v1 with: generate_release_notes: true app_model-0.2.0/.github/workflows/cron.yml0000644000000000000000000000341213615410400015452 0ustar00name: --pre Test # An "early warning" cron job that will install dependencies # with `pip install --pre` periodically to test for breakage # (and open an issue if a test fails) on: schedule: - cron: '0 */12 * * *' # every 12 hours workflow_dispatch: jobs: test: name: ${{ matrix.platform }} (${{ matrix.python-version }}) runs-on: ${{ matrix.platform }} strategy: fail-fast: false matrix: python-version: ['3.10'] platform: [ubuntu-latest, macos-latest, windows-latest] qt-backend: [PyQt5, PyQt6, PySide2, PySide6] include: - python-version: "3.8" platform: "ubuntu-latest" qt-backend: "PyQt5==5.12" steps: - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - uses: tlambert03/setup-qt-libs@v1 - name: Install dependencies run: | python -m pip install -U pip python -m pip install --pre -e .[qt,test,test-qt] python -m pip install --pre ${{ matrix.qt-backend }} - name: Test uses: aganders3/headless-gui@v1 with: run: python -m pytest -s --color=yes # If something goes wrong, we can open an issue in the repo - name: Report Failures if: ${{ failure() }} uses: JasonEtco/create-an-issue@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} PLATFORM: ${{ matrix.platform }} PYTHON: ${{ matrix.python }} RUN_ID: ${{ github.run_id }} TITLE: '[test-bot] pip install --pre is failing' with: filename: .github/TEST_FAIL_TEMPLATE.md update_existing: true app_model-0.2.0/demo/keybinding_helper.py0000644000000000000000000000037513615410400015356 0ustar00import 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.2.0/demo/model_app.py0000644000000000000000000002001313615410400013623 0ustar00from typing import List from fonticon_fa6 import FA6S 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.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 # noqa 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 # noqa 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" ACTIONS: List[types.Action] = [ types.Action( id="new_file", icon=FA6S.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=FA6S.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=FA6S.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=FA6S.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=FA6S.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=FA6S.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", 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.2.0/demo/qapplication.py0000644000000000000000000002017413615410400014357 0ustar00# 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.2.0/demo/multi_file/__init__.py0000644000000000000000000000000013615410400015545 0ustar00app_model-0.2.0/demo/multi_file/__main__.py0000644000000000000000000000042713615410400015543 0ustar00import pathlib import sys sys.path.append(str(pathlib.Path(__file__).parent.parent)) from qtpy.QtWidgets import QApplication # noqa: E402 from multi_file.app import MyApp # noqa: E402 qapp = QApplication.instance() or QApplication([]) app = MyApp() app.show() qapp.exec_() app_model-0.2.0/demo/multi_file/actions.py0000644000000000000000000000407613615410400015467 0ustar00from typing import List from fonticon_fa6 import FA6S 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=FA6S.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=FA6S.window_close, callback=functions.close, menus=[MenuRule(id=MenuId.FILE)], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyW)], ), Action( id=CommandId.UNDO, title="Undo", icon=FA6S.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=FA6S.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=FA6S.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=FA6S.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=FA6S.paste, callback=functions.paste, menus=[MenuRule(id=MenuId.EDIT, group="3_copypaste")], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyV)], ), ] app_model-0.2.0/demo/multi_file/app.py0000644000000000000000000000120113615410400014572 0ustar00from 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.2.0/demo/multi_file/constants.py0000644000000000000000000000071413615410400016036 0ustar00from 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.2.0/demo/multi_file/functions.py0000644000000000000000000000057413615410400016036 0ustar00from 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.2.0/docs/_macros.py0000644000000000000000000000361713615410400013325 0ustar00import collections.abc from importlib import import_module from typing import TYPE_CHECKING, Any, TypeVar, Union from pydantic import BaseModel from typing_extensions import ParamSpec if TYPE_CHECKING: from mkdocs_macros.plugin import MacrosPlugin def _import_attr(name: str): mod, attr = name.rsplit(".", 1) return getattr(import_module(mod), attr) def define_env(env: "MacrosPlugin") -> None: @env.macro def pydantic_table(name: str) -> str: cls = _import_attr(name) assert issubclass(cls, BaseModel) rows = ["| Field | Type | Description |", "| ---- | ---- | ----------- |"] if hasattr(cls, "model_fields"): fields = cls.model_fields else: fields = cls.__fields__ for fname, f in fields.items(): typ = f.outer_type_ if hasattr(f, "outer_type_") else f.annotation type_ = _build_type_link(typ) if hasattr(f, "field_info"): description = f.field_info.description or "" else: description = f.description row = f"| {fname} | {type_} | {description} |" rows.append(row) return "\n".join(rows) def _type_link(typ: Any) -> str: mod = f"{typ.__module__}." if typ.__module__ != "builtins" else "" type_fullpath = f"{mod}{typ.__name__}" return f"[`{typ.__name__}`][{type_fullpath}]" def _build_type_link(typ: Any) -> str: origin = getattr(typ, "__origin__", None) if origin is None: return _type_link(typ) args = getattr(typ, "__args__", ()) if origin is collections.abc.Callable and any( isinstance(a, (TypeVar, ParamSpec)) for a in args ): return _type_link(origin) types = [_build_type_link(a) for a in args if a is not type(None)] if origin is Union: return " or ".join(types) type_ = ", ".join(types) return f"{_type_link(origin)}[{type_}]" app_model-0.2.0/docs/application.md0000644000000000000000000000012413615410400014143 0ustar00# Application ::: app_model.Application options: show_signature: false app_model-0.2.0/docs/expressions.md0000644000000000000000000000046713615410400014234 0ustar00# Expressions ::: app_model.expressions.Expr options: members: - parse - eval - __str__ ::: app_model.expressions._expressions.parse_expression options: show_signature: yes show_signature_annotations: yes app_model-0.2.0/docs/index.md0000644000000000000000000001057713615410400012764 0ustar00# Overview `app-model` is a declarative, backend-agnostic schema for a GUI-based application. The primary goal of this library is to provide a set of types that enable an application developer to declare the commands, keybindings, macros, etc. that make up their application. ## General architecture Typical usage will begin by creating a [`Application`][app_model.Application] object. [Commands][app_model.types.CommandRule], [menu items][app_model.types.MenuRule], and [keybindings][app_model.types.KeyBindingRule] will usually be declared by creating [`Action`][app_model.Action] objects, and registered with the application using the [`Application.register_action`][app_model.Application.register_action] An application maintains a [registry](registries) for all registered [commands][app_model.registries.CommandsRegistry], [menus][app_model.registries.MenusRegistry], and [keybindings][app_model.registries.KeyBindingsRegistry]. !!! 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] ## Motivation Why bother with a declarative application model? 1. **It's easier to query the application's state** If you want to ask "what commands are available in this application?", or "what items are currently in a given menu", you can directly query the application registries. For example, you don't need to find a specific `QMenu` instance and iterate its `actions()` to know whether a given item is present. 1. **It's easier to modify the application's state** For applications that need to be dynamic (e.g. adding and removing menu items and actions as plugins are loaded and unloaded), it is convenient to have an application model that emits events when modified, with the "view" (the actual GUI backend) responding to those events to update the actual presentation. 1. **It decouples the structure of the application from the underlying backend** This makes it easier to change the backend without having to change the application. (Obviously, as an application grows with a particular backend, it does become harder to extract, but having a loosely coupled model is a step in the right direction) 1. **It's easier to test** `app-model` itself is comprehensively tested. By avoiding a number of one-off procedurally created menus, we can test reusable *patterns* of command/menu/keybinding creation and registration. ## Back Ends `app-model` is backend-agnostic, and can be used with any GUI toolkit, but [Qt](https://www.qt.io) is currently the primary target, and a Qt-backend comes with this library. ### Qt backend 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. ```python from app_model import Application, Action from app_model.backends.qt import QModelMenu app = Application("my-app") action = Action(id="my-action", ..., menus=[{'id': 'file', ...}]) app.register_action(action) qmenu = QModelMenu(menu_id='file', app=app) ``` !!! Tip Application [registries](registries) are backed by [psygnal](https://github.com/tlambert03/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. ### Example Application For a working example of a QApplication built with and without `app-model`, compare [`demo/model_app.py`](https://github.com/pyapp-kit/app-model/blob/main/demo/model_app.py) to [`demo/qapplication.py`](https://github.com/pyapp-kit/app-model/blob/main/demo/qapplication.py) in the `demo` directory of the `app-model` repository. app_model-0.2.0/docs/keybindings.md0000644000000000000000000000030213615410400014144 0ustar00# KeyCodes and KeyBindings ::: app_model.types.KeyCode options: show_signature_annotations: yes ::: app_model.types.KeyMod ::: app_model.types.KeyCombo ::: app_model.types.KeyChord app_model-0.2.0/docs/registries.md0000644000000000000000000000021413615410400014020 0ustar00# Registries ::: app_model.registries.CommandsRegistry ::: app_model.registries.KeyBindingsRegistry ::: app_model.registries.MenusRegistry app_model-0.2.0/docs/types.md0000644000000000000000000000110013615410400012777 0ustar00# App Model Types ::: app_model.types.CommandRule {{ pydantic_table('app_model.types.CommandRule') }} ::: app_model.types.ToggleRule {{ pydantic_table('app_model.types.ToggleRule') }} ::: app_model.types.MenuRule {{ pydantic_table('app_model.types.MenuRule') }} ::: app_model.types.KeyBindingRule {{ pydantic_table('app_model.types.KeyBindingRule') }} ::: app_model.types.Action options: show_bases: true {{ pydantic_table('app_model.types.Action') }} ::: app_model.types.Icon options: members: - {{ pydantic_table('app_model.types.Icon') }} app_model-0.2.0/src/app_model/__init__.py0000644000000000000000000000054113615410400015231 0ustar00"""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 .types import Action __all__ = ["__version__", "Application", "Action"] app_model-0.2.0/src/app_model/_app.py0000644000000000000000000001467513615410400014426 0ustar00from __future__ import annotations import contextlib from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, List, Optional, Tuple, Type import in_n_out as ino from psygnal import Signal from .registries import ( CommandsRegistry, KeyBindingsRegistry, MenusRegistry, register_action, ) if TYPE_CHECKING: from .types import Action from .types._constants import DisposeCallable 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 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. """ 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, ) -> 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 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 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 @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]() def register_action(self, action: Action) -> 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. This returns a function that may be called to undo the registration of `action`. """ return register_action(self, id_or_action=action) 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.2.0/src/app_model/_pydantic_compat.py0000644000000000000000000000247313615410400017015 0ustar00from __future__ import annotations from typing import Any, Callable, Literal, TypeVar from pydantic import BaseModel, __version__ PYDANTIC2 = __version__.startswith("2") M = TypeVar("M", bound=BaseModel) C = TypeVar("C", bound=Callable[..., Any]) # no-op for v1, put first for typing. def model_validator(*, mode: Literal["wrap", "before", "after"]) -> Callable[[C], C]: def decorator(func: C) -> C: return func return decorator if PYDANTIC2: from pydantic import field_validator from pydantic import model_validator as model_validator # type: ignore # noqa def validator(*args: Any, **kwargs: Any) -> Callable[[Callable], Callable]: return field_validator(*args, **kwargs) def asdict(obj: BaseModel, *args: Any, **kwargs: Any) -> dict: return obj.model_dump(*args, **kwargs) def asjson(obj: BaseModel, *args: Any, **kwargs: Any) -> str: return obj.model_dump_json(*args, **kwargs) else: from pydantic import validator as validator def asdict(obj: BaseModel, *args: Any, **kwargs: Any) -> dict: return obj.dict(*args, **kwargs) def asjson(obj: BaseModel, *args: Any, **kwargs: Any) -> str: return obj.json(*args, **kwargs) def model_config(**kwargs: Any) -> dict | type: return kwargs if PYDANTIC2 else type("Config", (), kwargs) app_model-0.2.0/src/app_model/py.typed0000644000000000000000000000000013615410400014605 0ustar00app_model-0.2.0/src/app_model/backends/__init__.py0000644000000000000000000000017113615410400017002 0ustar00"""Adapters for using the app_model with various backends.""" # TODO: make a `use_app()` like adapter to easily switch? app_model-0.2.0/src/app_model/backends/qt/__init__.py0000644000000000000000000000146513615410400017435 0ustar00"""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.2.0/src/app_model/backends/qt/_qaction.py0000644000000000000000000001413013615410400017464 0ustar00from __future__ import annotations import contextlib from typing import ( TYPE_CHECKING, ClassVar, Dict, Mapping, Optional, Tuple, Type, Union, cast, ) from qtpy.QtWidgets import QAction 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 qtpy.QtCore import QObject from app_model.types import CommandRule, MenuItem class QCommandAction(QAction): """Base QAction for a command id. Can execute the command. Parameters ---------- command_id : str Command ID. app : Union[str, Application] Application instance or name of application instance. parent : Optional[QWidget] Optional parent widget, by default None """ def __init__( self, command_id: str, app: Union[str, Application], parent: Optional[QObject] = 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) if kb := self._app.keybindings.get_keybinding(command_id): self.setShortcut(QKeyBindingSequence(kb.keybinding)) 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_id : str Command ID. app : Union[str, Application] Application instance or name of application instance. parent : Optional[QWidget] Optional parent widget, by default None """ def __init__( self, command_rule: CommandRule, app: Union[str, Application], parent: Optional[QObject] = 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)) 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() 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. """ _cache: ClassVar[Dict[Tuple[int, int], QMenuItemAction]] = {} def __new__( cls: Type[QMenuItemAction], menu_item: MenuItem, app: Union[str, Application], parent: Optional[QObject] = None, *, cache: bool = True, ) -> QMenuItemAction: """Create and cache a QMenuItemAction for the given menu item.""" app = Application.get_or_create(app) if isinstance(app, str) else app key = (id(app), hash(menu_item)) if cache and key in cls._cache: return cls._cache[key] self = cast(QMenuItemAction, super().__new__(cls)) if cache: cls._cache[key] = self return self def __init__( self, menu_item: MenuItem, app: Union[str, Application], parent: Optional[QObject] = None, *, cache: bool = True, # used in __new__ ): initialized = False with contextlib.suppress(RuntimeError): initialized = getattr(self, "_initialized", False) if not initialized: super().__init__(menu_item.command, app, parent) self._menu_item = menu_item key = (id(self._app), hash(menu_item)) self.destroyed.connect(lambda: QMenuItemAction._cache.pop(key, None)) self._app.destroyed.connect(lambda: QMenuItemAction._cache.pop(key, None)) self._initialized = True # by updating from an empty context, anything that declares a "constant" # enablement expression (like `'False'`) will be evaluated, allowing any # menus that are always on/off, to be shown/hidden as needed. # Everything else will fail without a proper context. # TODO: as we improve where the context comes from, this could be removed. with contextlib.suppress(NameError): self.update_from_context({}) 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.2.0/src/app_model/backends/qt/_qkeybindingedit.py0000644000000000000000000000125613615410400021205 0ustar00from 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.2.0/src/app_model/backends/qt/_qkeymap.py0000644000000000000000000004000413615410400017474 0ustar00import operator from functools import reduce from typing import Dict, Optional, Union, cast from qtpy.QtCore import QCoreApplication, Qt from qtpy.QtGui import QKeySequence from app_model.types._constants import OperatingSystem from app_model.types._keys import ( KeyBinding, KeyCode, KeyCombo, KeyMod, SimpleKeyBinding, ) 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.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 0 mods = (v for k, v in lookup.items() if getattr(skb, k)) combo = QKeyCombination(reduce(operator.or_, mods), key) return cast(int, combo.toCombined()) else: QKeyCombination = int 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: 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[Optional[KeyCode], 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: Dict[Qt.Key, KeyCode] = { 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 = { 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: """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) -> Union[KeyCode, KeyCombo]: """Return KeyCode or KeyCombo from QKeyCombination.""" if key in KEY_FROM_QT: return KEY_FROM_QT[key] qmods = _get_qmods(key) qkey = _get_qkey(key) return qmods2modelmods(qmods) | qkey2modelkey(qkey) def qkeysequence2modelkeybinding(key: QKeySequence) -> KeyBinding: """Return KeyBinding from QKeySequence.""" # FIXME: this should return KeyChord instead of KeyBinding... but that only takes 2 return KeyBinding( parts=[SimpleKeyBinding.from_int(qkeycombo2modelkey(x)) for x in key] ) # ################# 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.2.0/src/app_model/backends/qt/_qmainwindow.py0000644000000000000000000000275013615410400020370 0ustar00from __future__ import annotations from typing import Collection, Mapping, Optional, Sequence, Union 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: Union[str, Application], parent: Optional[QWidget] = 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: Optional[Collection[str]] = None, area: Optional[Qt.ToolBarArea] = None, ) -> None: """Add a tool bar to the main window.""" menu_bar = QModelToolBar(menu_id, self._app, exclude=exclude, parent=self) if area is not None: self.addToolBar(area, menu_bar) else: self.addToolBar(menu_bar) app_model-0.2.0/src/app_model/backends/qt/_qmenu.py0000644000000000000000000003104513615410400017157 0ustar00from __future__ import annotations from typing import ( TYPE_CHECKING, Collection, Iterable, Mapping, Optional, Sequence, Set, Union, 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 : Union[str, Application] Application instance or name of application instance. title : Optional[str] Optional title for the menu, by default None parent : Optional[QWidget] Optional parent widget, by default None """ def __init__( self, menu_id: str, app: Union[str, Application], title: Optional[str] = None, parent: Optional[QWidget] = 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) -> Union[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: Optional[Collection[str]] = 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: # this would also be a reasonable place to call 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: 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 : Union[str, Application] Application instance or name of application instance. parent : Optional[QWidget] Optional parent widget, by default None """ def __init__( self, submenu: SubmenuItem, app: Union[str, Application], parent: Optional[QWidget] = 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) 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 : Union[str, Application] Application instance or name of application instance. exclude : Optional[Collection[str]] Optional list of menu ids to exclude from the toolbar, by default None title : Optional[str] Optional title for the menu, by default None parent : Optional[QWidget] Optional parent widget, by default None """ def __init__( self, menu_id: str, app: Union[str, Application], *, exclude: Optional[Collection[str]] = None, title: Optional[str] = None, parent: Optional[QWidget] = 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) -> Union[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: Optional[Collection[str]] = 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 : Union[str, Application] Application instance or name of application instance. parent : Optional[QWidget] Optional parent widget, by default None """ def __init__( self, menus: Mapping[str, str] | Sequence[str | tuple[str, str]], app: Union[str, Application], parent: Optional[QWidget] = 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: Optional[Collection[str]] = None, ) -> None: """Rebuild menu by looking up `menu` in `Application`'s menu_registry.""" menu.clear() _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(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) -> Union[QAction, None]: return next((a for a in actions if a.objectName() == object_name), None) app_model-0.2.0/src/app_model/backends/qt/_util.py0000644000000000000000000000067713615410400017016 0ustar00from __future__ import annotations from typing import TYPE_CHECKING 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 fonticon if icn := getattr(icon, theme, ""): return fonticon.icon(icn) return QIcon() # pragma: no cover app_model-0.2.0/src/app_model/expressions/__init__.py0000644000000000000000000000121413615410400017611 0ustar00"""Abstraction on expressions, and contexts in which to evaluate them.""" from ._context import 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__ = [ "BinOp", "BoolOp", "Compare", "Constant", "IfExp", "Name", "UnaryOp", "Context", "ContextKey", "ContextKeyInfo", "ContextNamespace", "create_context", "Expr", "get_context", "parse_expression", "safe_eval", ] app_model-0.2.0/src/app_model/expressions/_context.py0000644000000000000000000001134713615410400017705 0ustar00from __future__ import annotations import sys from contextlib import contextmanager from types import FrameType from typing import ( Any, Callable, ChainMap, Dict, Iterator, MutableMapping, Optional, Type, ) from weakref import finalize from psygnal import Signal _null = object() class Context(ChainMap): """Evented Mapping of keys to values.""" changed = Signal(set) # Set[str] @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: Optional[MutableMapping] = 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: Optional[Context] = 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: Optional[Context] = 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: Optional[FrameType] = 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) -> Optional[Context]: """Return context for any object, if found.""" return _OBJ_TO_CONTEXT.get(id(obj)) app_model-0.2.0/src/app_model/expressions/_context_keys.py0000644000000000000000000001602413615410400020735 0ustar00from __future__ import annotations import contextlib from types import MappingProxyType from typing import ( Any, Callable, ClassVar, Dict, Generic, List, Literal, MutableMapping, NamedTuple, Optional, Type, TypeVar, Union, overload, ) from ._expressions import Name 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: Optional[Type] description: Optional[str] namespace: Optional[Type[ContextNamespace]] 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: Union[T, __missing] = MISSING, description: Optional[str] = None, getter: Optional[Callable[[A], T]] = 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: Optional[Type[ContextNamespace]] = 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 ValueError( f"Cannot change id of ContextKey (already {self.id!r})", ) self._owner = owner self.id = name 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: Optional[ContextNamespace[A]], objtype: Type ) -> Union[T, None, ContextKey[A, T]]: """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.""" def __new__( cls: Type, clsname: str, bases: tuple, attrs: dict ) -> Type[ContextNamespace]: """Create a new ContextNamespace class.""" cls = super().__new__(cls, clsname, bases, attrs) cls._members_map_ = { k: v for k, v in attrs.items() if isinstance(v, ContextKey) } return 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.2.0/src/app_model/expressions/_expressions.py0000644000000000000000000004617113615410400020606 0ustar00"""Provides the :class:`Expr` and its subclasses.""" from __future__ import annotations import ast from typing import ( TYPE_CHECKING, Any, Callable, Dict, Generic, Iterator, List, Mapping, Optional, Sequence, SupportsIndex, Tuple, 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 pydantic.annotated import GetCoreSchemaHandler from pydantic_core import core_schema from ._context_keys import ContextKey def parse_expression(expr: Union[str, Expr]) -> Expr: """Parse string expression into an :class:`Expr` instance. Parameters ---------- expr : Union[str, Expr] Expression to parse. (If already an :class:`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 ExprTranformer().visit(tree.body) except SyntaxError as e: raise SyntaxError(f"{expr!r} is not a valid expression: ({e}).") from None def safe_eval(expr: Union[str, bool, Expr], context: Optional[Mapping] = 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 : Union[str, bool, Expr] Expression to evaluate. If `expr` is a string, it is parsed into an :class:`Expr` instance. If a `bool`, it is returned directly. context : Optional[Mapping] Context (mapping of names to objects) to evaluate the expression in. """ if isinstance(expr, bool): return expr return parse_expression(expr).eval(context or {}) 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!' 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` """ 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) ast.fix_missing_locations(self) def eval(self, context: Optional[Mapping[str, object]] = None) -> T: """Evaluate this expression with names in `context`.""" if context is None: context = {} code = compile(ast.Expression(body=self), "", "eval") try: return cast(T, eval(code, {}, context)) except NameError as e: miss = {k for k in _iter_names(self) 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: Union[Expr[T2], Expr[T], ConstType, Compare] ) -> BoolOp[Union[T, T2]]: return BoolOp(ast.And(), [self, other]) def __or__( self, other: Union[Expr[T2], Expr[T], ConstType, Compare] ) -> BoolOp[Union[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: Union[T, Expr[T]]) -> BinOp[T]: return BinOp(self, ast.Add(), other) def __sub__(self, other: Union[T, Expr[T]]) -> BinOp[T]: return BinOp(self, ast.Sub(), other) def __mul__(self, other: Union[T, Expr[T]]) -> BinOp[T]: return BinOp(self, ast.Mult(), other) def __truediv__(self, other: Union[T, Expr[T]]) -> BinOp[T]: return BinOp(self, ast.Div(), other) def __floordiv__(self, other: Union[T, Expr[T]]) -> BinOp[T]: return BinOp(self, ast.FloorDiv(), other) def __mod__(self, other: Union[T, Expr[T]]) -> BinOp[T]: return BinOp(self, ast.Mod(), other) def __matmul__(self, other: Union[T, Expr[T]]) -> BinOp[T]: return BinOp(self, ast.MatMult(), other) # pragma: no cover def __pow__(self, other: Union[T, Expr[T]]) -> BinOp[T]: return BinOp(self, ast.Pow(), other) def __xor__(self, other: Union[T, Expr[T]]) -> BinOp[T]: return BinOp(self, ast.BitXor(), other) def bitand(self, other: Union[T, Expr[T]]) -> BinOp[T]: """Return bitwise self & other.""" return BinOp(self, ast.BitAnd(), other) def bitor(self, other: Union[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: 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 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) def eval(self, context: Optional[Mapping] = None) -> T: """Evaluate this expression with names in `context`.""" if context is None: context = {} return super().eval(context=context) 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: Optional[str] = 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: Union[T, Expr[T]], op: ast.operator, right: Union[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 :class:`UnaryOp`. """ def __init__( self, op: ast.boolop, values: Sequence[Union[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 ExprTranformer(ast.NodeTransformer): """Transformer that converts an ast.expr into an :class:`Expr`. Examples -------- >>> tree = ast.parse('my_var > 11', mode='eval') >>> tree = ExprTranformer().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) -> Optional[ast.AST]: """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 ExprTranformer._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 :class:`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: Optional[Expr] = None) -> None: self._result: List[str] = [] def write(*params: Union[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_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.2.0/src/app_model/registries/__init__.py0000644000000000000000000000053313615410400017412 0ustar00"""App-model registries, such as menus, keybindings, commands.""" from ._commands_reg import CommandsRegistry from ._keybindings_reg import KeyBindingsRegistry from ._menus_reg import MenusRegistry from ._register import register_action __all__ = [ "CommandsRegistry", "KeyBindingsRegistry", "MenusRegistry", "register_action", ] app_model-0.2.0/src/app_model/registries/_commands_reg.py0000644000000000000000000001505113615410400020451 0ustar00from __future__ import annotations from concurrent.futures import Future, ThreadPoolExecutor from functools import cached_property from typing import ( TYPE_CHECKING, Any, Callable, Dict, Generic, Iterator, Optional, Tuple, TypeVar, Union, 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 P = ParamSpec("P") else: try: from typing_extensions import ParamSpec P = ParamSpec("P") except ImportError: P = TypeVar("P") DisposeCallable = Callable[[], None] R = TypeVar("R") class _RegisteredCommand(Generic[P, R]): """Small object to represent a command in the CommandsRegistry. Only used internally by the CommandsRegistry. This helper class allows us to cache the dependency-injected variant of the command. As usual with `cached_property`, the cache can be cleard by deleting the attribute: `del cmd.run_injected` """ def __init__( self, id: str, callback: Union[str, Callable[P, R]], title: str, store: Optional[Store] = None, ) -> None: self.id = id self.callback = callback self.title = title self._resolved_callback = callback if callable(callback) else None self._injection_store: Store = store or Store.get_store() @property def resolved_callback(self) -> Callable[P, R]: if self._resolved_callback is None: from app_model.types._utils import import_python_name try: self._resolved_callback = import_python_name(str(self.callback)) except ImportError as e: self._resolved_callback = cast(Callable[P, R], 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(self._resolved_callback): # don't try to import again, just create a no-op self._resolved_callback = cast(Callable[P, R], 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." ) return self._resolved_callback @cached_property def run_injected(self) -> Callable[P, R]: return self._injection_store.inject(self.resolved_callback, processors=True) class CommandsRegistry: """Registry for commands (callable objects).""" registered = Signal(str) def __init__( self, injection_store: Optional[Store] = 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_command( self, id: str, callback: Union[str, Callable], 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") 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.2.0/src/app_model/registries/_keybindings_reg.py0000644000000000000000000000511313615410400021154 0ustar00from __future__ import annotations from typing import TYPE_CHECKING, Callable, NamedTuple, Optional from psygnal import Signal from app_model.types._keys import KeyBinding if TYPE_CHECKING: from typing import Iterator, List, TypeVar from app_model import expressions from app_model.types import KeyBindingRule DisposeCallable = Callable[[], None] 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: Optional[expressions.Expr] = None # condition to enable keybinding class KeyBindingsRegistry: """Registry for keybindings.""" registered = Signal() def __init__(self) -> None: self._keybindings: List[_RegisteredKeyBinding] = [] def register_keybinding_rule( self, id: str, rule: KeyBindingRule ) -> Optional[DisposeCallable]: """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) 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) -> Optional[_RegisteredKeyBinding]: """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.2.0/src/app_model/registries/_menus_reg.py0000644000000000000000000001134013615410400017774 0ustar00from __future__ import annotations from typing import ( Any, Callable, Dict, Final, Iterable, Iterator, List, Optional, Sequence, Set, Tuple, ) from psygnal import Signal from app_model.types import MenuItem, MenuOrSubmenu from app_model.types._constants import DisposeCallable 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_menu_items( self, items: Sequence[Tuple[MenuId, MenuOrSubmenu]] ) -> DisposeCallable: """Append menu items to the registry. Parameters ---------- items : Sequence[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[Optional[str], 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.2.0/src/app_model/registries/_register.py0000644000000000000000000002107413615410400017641 0ustar00from __future__ import annotations from typing import TYPE_CHECKING, TypeVar, overload from app_model._pydantic_compat import asdict from app_model.types import Action, MenuItem if TYPE_CHECKING: from typing import Any, Callable, List, Literal, Optional, Union from app_model import expressions from app_model._app import Application from app_model.types import IconOrDict, KeyBindingRuleOrDict, MenuRuleOrDict from app_model.types._constants import DisposeCallable CommandCallable = TypeVar("CommandCallable", bound=Callable[..., Any]) CommandDecorator = Callable[[Callable], Callable] @overload def register_action( app: Union[Application, str], id_or_action: Action ) -> DisposeCallable: ... @overload def register_action( app: Union[Application, str], id_or_action: str, title: str, *, callback: Literal[None] = ..., category: Optional[str] = ..., tooltip: Optional[str] = ..., icon: Optional[IconOrDict] = ..., enablement: Optional[expressions.Expr] = ..., menus: Optional[List[MenuRuleOrDict]] = ..., keybindings: Optional[List[KeyBindingRuleOrDict]] = ..., palette: bool = True, ) -> CommandDecorator: ... @overload def register_action( app: Union[Application, str], id_or_action: str, title: str, *, callback: CommandCallable, category: Optional[str] = ..., tooltip: Optional[str] = ..., icon: Optional[IconOrDict] = ..., enablement: Optional[expressions.Expr] = ..., menus: Optional[List[MenuRuleOrDict]] = ..., keybindings: Optional[List[KeyBindingRuleOrDict]] = ..., palette: bool = True, ) -> DisposeCallable: ... def register_action( app: Union[Application, str], id_or_action: Union[str, Action], title: Optional[str] = None, *, callback: Optional[CommandCallable] = None, category: Optional[str] = None, tooltip: Optional[str] = None, icon: Optional[IconOrDict] = None, enablement: Optional[expressions.Expr] = None, menus: Optional[List[MenuRuleOrDict]] = None, keybindings: Optional[List[KeyBindingRuleOrDict]] = None, palette: bool = True, ) -> Union[CommandDecorator, DisposeCallable]: """Register an action. An Action is the "complete" representation of a command. The command is the function itself, and an action also includes information about where and whether it appears in menus and optional keybinding rules. see also docstrings for: - :class:`~app_model._types.Action` - :class:`~app_model._types.CommandRule` - :class:`~app_model._types.MenuRule` - :class:`~app_model._types.KeyBindingRule` This function can be used directly or as a decorator: - When the first `id_or_action` argument is an `Action`, then all other arguments are ignored, the action object is registered directly, and a function that may be used to unregister the action is returned. - When the first `id_or_action` argument is a string, it is interpreted as the `id` of the command being registered, and `title` must then also be provided. If `run` is not provided, then a decorator is returned that can be used to decorate the callable that executes the command; otherwise the command is registered directly and a function that may be used to unregister the action is returned. Parameters ---------- app: Union[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)`. id_or_action : Union[CommandId, 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 : Optional[str] Title by which the command is represented in the UI. Required when `id_or_action` is a string. callback : Optional[CommandHandler] 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 : Optional[str] Category string by which the command may be grouped in the UI, by default None tooltip : Optional[str] Tooltip to show when hovered., by default None icon : Optional[Icon] :class:`~app_model._types.Icon` used to represent this command, e.g. on buttons or in menus. by default None enablement : Optional[context.Expr] Condition which must be true to enable the command in in the UI, by default None menus : Optional[List[MenuRuleOrDict]] :class:`~app_model._types.MenuRule` or `dicts` containing menu placements for this action, by default None keybindings : Optional[List[KeyBindingRuleOrDict]] :class:`~app_model._types.KeyBindingRule` or `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 ------- Union[CommandDecorator, DisposeCallable] If `run` is not provided, then a decorator is returned. If `run` is provided, or `id_or_action` is an `Action` object, then a function that may be used to unregister the action is returned. 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. """ 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: Union[Application, str], **kwargs: Any, ) -> Union[CommandDecorator, DisposeCallable]: """Create and register an Action with a string id and title. Helper for `register_action()`. If `kwargs['run']` 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 callable(kwargs.get("callback")): return _register_action_obj(app, Action(**kwargs)) def decorator(command: CommandCallable, **k: Any) -> CommandCallable: _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: Union[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) # command disp_cmd = app.commands.register_command(action.id, action.callback, action.title) disposers = [disp_cmd] # menu items = [] for rule in action.menus or (): menu_item = MenuItem( command=action, when=rule.when, group=rule.group, order=rule.order ) items.append((rule.id, menu_item)) disposers.append(app.menus.append_menu_items(items)) if action.palette: menu_item = MenuItem(command=action, when=action.enablement) disp = app.menus.append_menu_items([(app.menus.COMMAND_PALETTE_ID, menu_item)]) disposers.append(disp) # keybinding for keyb in action.keybindings or (): if action.enablement is not None: kwargs = asdict(keyb) kwargs["when"] = ( action.enablement if keyb.when is None else action.enablement | keyb.when ) _keyb = type(keyb)(**kwargs) else: _keyb = keyb if _d := app.keybindings.register_keybinding_rule(action.id, _keyb): disposers.append(_d) def _dispose() -> None: for d in disposers: d() app._disposers.append((action.id, _dispose)) return _dispose app_model-0.2.0/src/app_model/types/__init__.py0000644000000000000000000000163413615410400016401 0ustar00"""App-model types.""" from ._action import Action from ._command_rule import CommandRule, ToggleRule from ._icon import Icon, IconOrDict from ._keybinding_rule import KeyBindingRule, KeyBindingRuleDict, KeyBindingRuleOrDict from ._keys import ( KeyBinding, KeyChord, KeyCode, KeyCombo, KeyMod, SimpleKeyBinding, StandardKeyBinding, ) from ._menu_rule import ( MenuItem, MenuOrSubmenu, MenuRule, MenuRuleDict, MenuRuleOrDict, SubmenuItem, ) __all__ = [ "Action", "CommandRule", "Icon", "IconOrDict", "KeyBinding", "KeyBindingRule", "KeyBindingRuleDict", "KeyBindingRuleOrDict", "KeyChord", "KeyCode", "KeyCombo", "KeyMod", "MenuItem", "MenuOrSubmenu", "MenuRule", "MenuRuleDict", "MenuRuleOrDict", "ScanCode", "SimpleKeyBinding", "StandardKeyBinding", "SubmenuItem", "ToggleRule", ] app_model-0.2.0/src/app_model/types/_action.py0000644000000000000000000000446613615410400016264 0ustar00from __future__ import annotations from typing import TYPE_CHECKING, Callable, Generic, List, Optional, TypeVar, Union from pydantic import Field from app_model._pydantic_compat import 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]): """Callable object along with specific context, menu, keybindings logic. 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.", ) 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.", ) @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.2.0/src/app_model/types/_base.py0000644000000000000000000000104413615410400015706 0ustar00from typing import TYPE_CHECKING, cast from pydantic import BaseModel from app_model._pydantic_compat import PYDANTIC2, model_config if TYPE_CHECKING: from pydantic import ConfigDict # don't switch to exclude ... it makes it hard to add fields to the # schema without breaking backwards compatibility _config = model_config(extra="ignore", frozen=True) class _BaseModel(BaseModel): """Base model for all types.""" if PYDANTIC2: model_config = cast("ConfigDict", _config) else: Config = _config # type: ignore app_model-0.2.0/src/app_model/types/_command_rule.py0000644000000000000000000000573013615410400017447 0ustar00from typing import Callable, Optional, Union from pydantic 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 superqt fonticon keys, such as `fa6s.arrow_down`", ) 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.2.0/src/app_model/types/_constants.py0000644000000000000000000000213113615410400017006 0ustar00import os import sys from enum import Enum from typing import Callable DisposeCallable = Callable[[], None] 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.2.0/src/app_model/types/_icon.py0000644000000000000000000000304613615410400015730 0ustar00from typing import Any, Callable, Generator, Optional, TypedDict, Union from pydantic import Field from app_model._pydantic_compat import 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 superqt " "fonticon keys, such as `fa6s.arrow_down`", ) light: Optional[str] = Field( None, description="Icon path when a light theme is used. These may be superqt " "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="wrap") @classmethod def _model_val(cls, v: Any, handler: Callable[[Any], "Icon"]) -> "Icon": if isinstance(v, str): v = {"dark": v, "light": v} return handler(v) class IconDict(TypedDict): """Icon dictionary.""" dark: Optional[str] light: Optional[str] IconOrDict = Union[Icon, IconDict] app_model-0.2.0/src/app_model/types/_keybinding_rule.py0000644000000000000000000000534413615410400020155 0ustar00from typing import Any, Callable, Optional, Type, TypedDict, TypeVar, Union from pydantic import Field from app_model import expressions from app_model._pydantic_compat import model_validator 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 @classmethod def validate(cls, value: Any) -> "KeyBindingRule": """Validate keybinding rule.""" if isinstance(value, StandardKeyBinding): return value.to_keybinding_rule() return super().validate(value) # 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 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.2.0/src/app_model/types/_menu_rule.py0000644000000000000000000000775213615410400017003 0ustar00from typing import ( Any, Callable, Generator, Optional, Type, TypedDict, Union, ) from pydantic import Field from app_model import expressions from app_model._pydantic_compat import validator 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.") 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)", ) @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 superqt 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.2.0/src/app_model/types/_utils.py0000644000000000000000000000333213615410400016136 0ustar00import 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.2.0/src/app_model/types/_keys/__init__.py0000644000000000000000000000052013615410400017504 0ustar00from ._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.2.0/src/app_model/types/_keys/_key_codes.py0000644000000000000000000010243513615410400020061 0ustar00from enum import IntEnum, IntFlag, auto from typing import ( TYPE_CHECKING, Any, Callable, Dict, Generator, NamedTuple, Set, Tuple, Type, Union, overload, ) if TYPE_CHECKING: from pydantic.annotated 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: return keycode_to_string(self) @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 codea required to conform to the W3C specification for KeyboardEvent.code 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[[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, } 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 _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, _scancode_to_string, _scancode_from_string, ) ( keycode_to_string, keycode_from_string, 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][]. """ 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.2.0/src/app_model/types/_keys/_keybindings.py0000644000000000000000000002335613615410400020426 0ustar00import re from typing import TYPE_CHECKING, Any, Callable, Dict, Generator, List, Optional, Tuple from pydantic import BaseModel, Field from app_model._pydantic_compat import PYDANTIC2, model_validator from app_model.types._constants import OperatingSystem from ._key_codes import KeyChord, KeyCode, KeyMod if TYPE_CHECKING: from pydantic.annotated import GetCoreSchemaHandler from pydantic_core import core_schema _re_ctrl = re.compile(r"ctrl[\+|\-]") _re_shift = re.compile(r"shift[\+|\-]") _re_alt = re.compile(r"alt[\+|\-]") _re_meta = re.compile(r"meta[\+|\-]") _re_win = re.compile(r"win[\+|\-]") _re_cmd = re.compile(r"cmd[\+|\-]") class SimpleKeyBinding(BaseModel): """Represent a simple combination modifier(s) and a key, e.g. Ctrl+A.""" ctrl: bool = False shift: bool = False alt: bool = False meta: bool = False key: Optional[KeyCode] = None # 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: 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) @classmethod def _parse_input(cls, v: Any) -> Optional["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) return None @classmethod def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: yield cls._validate # pragma: no cover @classmethod def _validate(cls, input: Any) -> "SimpleKeyBinding": return cls._parse_input(input) or cls(**input) # pragma: no cover # for v2 @model_validator(mode="wrap") @classmethod def _model_val( cls, input: Any, handler: Callable[[Any], "SimpleKeyBinding"] ) -> "SimpleKeyBinding": return cls._parse_input(input) or handler(input) 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 :class:`SimplyKeyBinding`, 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'. """ def __init__(self, *, parts: List[SimpleKeyBinding]): self.parts = parts parts: List[SimpleKeyBinding] = Field(..., **MIN1) # type: ignore def __str__(self) -> str: return " ".join(str(part) for part in self.parts) def __eq__(self, other: Any) -> bool: if not isinstance(other, KeyBinding): try: other = KeyBinding.validate(other) except Exception: # pragma: no cover return NotImplemented return bool(self.parts == other.parts) 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 SimpleKeyBinding 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 __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 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() ctrl = False shift = False alt = False meta = False while True: saw_modifier = False if _re_ctrl.match(remainder): remainder = remainder[5:] ctrl = True saw_modifier = True if _re_shift.match(remainder): remainder = remainder[6:] shift = True saw_modifier = True if _re_alt.match(remainder): remainder = remainder[4:] alt = True saw_modifier = True if _re_meta.match(remainder): remainder = remainder[5:] meta = True saw_modifier = True if _re_win.match(remainder): remainder = remainder[4:] meta = True saw_modifier = True if _re_cmd.match(remainder): remainder = remainder[4:] meta = True saw_modifier = True if not saw_modifier: break return {"ctrl": ctrl, "shift": shift, "alt": alt, "meta": meta}, remainder app_model-0.2.0/src/app_model/types/_keys/_standard_bindings.py0000644000000000000000000001716713615410400021600 0ustar00from 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() 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.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.2.0/tests/conftest.py0000644000000000000000000001573713615410400013747 0ustar00import sys from pathlib import Path from typing import List from unittest.mock import Mock import pytest from app_model import Action, Application from app_model.types import KeyCode, KeyMod, SubmenuItem try: from fonticon_fa6 import FA6S UNDO_ICON = FA6S.rotate_left except ImportError: UNDO_ICON = "fa6s.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(): 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="fa6s.copy", 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="fa6s.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) -> 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(): 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.2.0/tests/test_actions.py0000644000000000000000000000723013615410400014606 0ustar00from typing import List import pytest from app_model import Application from app_model.registries import register_action from app_model.types import Action PRIMARY_KEY = "ctrl+a" OS_KEY = "ctrl+b" MENUID = "some.menu.id" KWARGS = [ {}, {"enablement": "x == 1"}, {"menus": [{"id": MENUID}]}, {"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, simple_app: Application, mode): # 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: assert entry["id"] in app.menus app.menus_changed.assert_any_call({entry["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] 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.2.0/tests/test_app.py0000644000000000000000000000625713615410400013736 0ustar00from __future__ import annotations import sys from typing import TYPE_CHECKING import pytest from app_model import Application if TYPE_CHECKING: from conftest import FullApp def test_app_create(): 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): 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): 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): """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): 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) app_model-0.2.0/tests/test_command_registry.py0000644000000000000000000000235613615410400016520 0ustar00import pytest from app_model.registries import CommandsRegistry def raise_exc() -> None: raise RuntimeError("boom") def test_commands_registry() -> None: reg = CommandsRegistry() reg.register_command("my.id", lambda: 42, "My Title") assert "(1 commands)" in repr(reg) assert "my.id" in str(reg) with pytest.raises(ValueError, match="Command 'my.id' already registered"): reg.register_command("my.id", lambda: 42, "My Title") assert reg.execute_command("my.id", execute_asynchronously=True).result() == 42 assert reg.execute_command("my.id", 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) reg.register_command("my.id", raise_exc, "My Title") with pytest.raises(RuntimeError, match="boom"): reg.execute_command("my.id") app_model-0.2.0/tests/test_key_codes.py0000644000000000000000000000236013615410400015112 0ustar00import pytest from app_model.types._keys import KeyChord, KeyCode, KeyMod, ScanCode, SimpleKeyBinding 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"}) 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.2.0/tests/test_keybindings.py0000644000000000000000000000674413615410400015465 0ustar00import sys from typing import ClassVar import pytest from pydantic import BaseModel from app_model._pydantic_compat import PYDANTIC2, asjson from app_model.types import ( KeyBinding, KeyBindingRule, KeyCode, KeyMod, SimpleKeyBinding, ) from app_model.types._keys import KeyChord, KeyCombo, StandardKeyBinding MAC = sys.platform == "darwin" @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(): # 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() def test_chord_keybinding(): kb = KeyBinding.from_str("Shift+A Cmd+9") assert len(kb) == 2 assert kb == "Shift+A Cmd+9" assert kb == KeyBinding.from_str("Shift+A Cmd+9") assert kb.part0 == SimpleKeyBinding(shift=True, key=KeyCode.KeyA) assert kb.part0 == "Shift+A" # 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(): 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(): 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 asjson(m).replace('": "', '":"') == '{"key":"Shift+A B"}' def test_standard_keybindings(): class M(BaseModel): key: KeyBindingRule m = M(key=StandardKeyBinding.Copy) assert m.key.primary == KeyMod.CtrlCmd | KeyCode.KeyC app_model-0.2.0/tests/test_registries.py0000644000000000000000000000115613615410400015327 0ustar00from app_model.registries import KeyBindingsRegistry, MenusRegistry from app_model.types import 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) app_model-0.2.0/tests/test_types.py0000644000000000000000000000123113615410400014305 0ustar00import 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") app_model-0.2.0/tests/fixtures/fake_module.py0000644000000000000000000000022513615410400016230 0ustar00from unittest.mock import Mock GLOBAL_MOCK = Mock(name="GLOBAL") def run_me() -> bool: GLOBAL_MOCK() return True attr = "not a callble" app_model-0.2.0/tests/test_context/test_context.py0000644000000000000000000000452313615410400017357 0ustar00import 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"}) app_model-0.2.0/tests/test_context/test_context_keys.py0000644000000000000000000000360013615410400020405 0ustar00import pytest from app_model.expressions._context_keys import ( ContextKey, ContextKeyInfo, ContextNamespace, ) def test_context_key_info(): 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(): 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 = {} 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}" def test_good_naming(): 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.2.0/tests/test_context/test_expressions.py0000644000000000000000000001552013615410400020254 0ustar00import 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) 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", "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") else: 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")', "(1,)", # tuples not yet supported '{"key": "val"}', # dicts not yet supported '{"hi"}', # set constant "[]", # lists constant "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 with pytest.raises(SyntaxError, match="Type 'Call' not supported"): safe_eval("func(x)") with pytest.raises(SyntaxError, match="Type 'Set' not supported"): safe_eval("{1,2,3}") @pytest.mark.parametrize("expr", GOOD_EXPRESSIONS) def test_hash(expr): assert isinstance(hash(parse_expression(expr)), int) app_model-0.2.0/tests/test_qt/__init__.py0000644000000000000000000000017213615410400015327 0ustar00import pytest try: import qtpy # noqa except ImportError: pytest.skip("No Qt backend", allow_module_level=True) app_model-0.2.0/tests/test_qt/test_demos.py0000644000000000000000000000060413615410400015736 0ustar00import 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"]) 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.2.0/tests/test_qt/test_qactions.py0000644000000000000000000000260113615410400016447 0ustar00from typing import TYPE_CHECKING from unittest.mock import Mock from app_model.backends.qt import QCommandRuleAction, QMenuItemAction from app_model.types import Action, MenuItem, ToggleRule if TYPE_CHECKING: pass 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(action, full_app) a2 = QMenuItemAction(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() app_model-0.2.0/tests/test_qt/test_qkeybindingedit.py0000644000000000000000000000063213615410400020002 0ustar00from 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.2.0/tests/test_qt/test_qkeymap.py0000644000000000000000000001553513615410400016307 0ustar00from 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.2.0/tests/test_qt/test_qmainwindow.py0000644000000000000000000000124313615410400017164 0ustar00from typing import TYPE_CHECKING from qtpy.QtCore import Qt from app_model.backends.qt import QModelMainWindow if TYPE_CHECKING: from ..conftest import FullApp 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"] win.addModelToolBar(full_app.Menus.FILE) win.addModelToolBar(full_app.Menus.EDIT, area=Qt.ToolBarArea.RightToolBarArea) app_model-0.2.0/tests/test_qt/test_qmenu.py0000644000000000000000000001260213615410400015755 0ustar00from __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) 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() # "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.2.0/.gitignore0000644000000000000000000000233613615410400012365 0ustar00# 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.2.0/LICENSE0000644000000000000000000000275113615410400011403 0ustar00BSD License Copyright (c) 2022, Talley Lambert All rights reserved. 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.2.0/README.md0000644000000000000000000000202213615410400011644 0ustar00# 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.2.0/pyproject.toml0000644000000000000000000001066613615410400013316 0ustar00# 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", "Topic :: Desktop Environment", "Topic :: Software Development", "Topic :: Software Development :: User Interfaces", ] dynamic = ["version"] dependencies = [ "psygnal>=0.3.4", "pydantic>=1.8", "in-n-out>=0.1.5", "typing_extensions", ] # extras # https://peps.python.org/pep-0621/#dependencies-optional-dependencies [project.optional-dependencies] test = ["pytest>=6.0", "pytest-cov"] test-qt = ["pytest-qt", "fonticon-fontawesome6"] qt = ["qtpy", "superqt"] dev = [ "black", "ipython", "isort", "mypy", "pdbpp", "pre-commit", "pydocstyle", "pytest-cov", "pytest", "rich", ] docs = [ "griffe==0.22.0", "mkdocs-material~=8.3", "mkdocs-minify-plugin==0.5.0", "mkdocs==1.3.0", "mkdocstrings-python==0.7.0", "mkdocstrings==0.19.0", "mkdocs-macros-plugin==0.7.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" select = [ "E", # style errors "W", # style warnings "F", # flakes "D", # pydocstyle "I", # isort "UP", # pyupgrade "C4", # flake8-comprehensions "B", # flake8-bugbear "A001", # flake8-builtins "RUF", # ruff-specific rules ] ignore = [ "D100", # Missing docstring in public module "D107", # Missing docstring in __init__ "D203", # 1 blank line required before class docstring "D212", # Multi-line docstring summary should start at the first line "D213", # Multi-line docstring summary should start at the second line "D413", # Missing blank line after last section "D416", # Section name should end with a colon ] [tool.ruff.pyupgrade] # Preserve types, even if a file imports `from __future__ import annotations`. keep-runtime-typing = true [tool.ruff.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"] "setup.py" = ["F821"] # 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.*"] 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", "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/**/*", ] app_model-0.2.0/PKG-INFO0000644000000000000000000000634313615410400011474 0ustar00Metadata-Version: 2.1 Name: app-model Version: 0.2.0 Summary: Generic application schema implemented in python Project-URL: homepage, https://github.com/pyapp-kit/app-model Project-URL: repository, https://github.com/pyapp-kit/app-model Author: Talley Lambert Author-email: talley.lambert@gmail.com License: BSD 3-Clause License License-File: LICENSE Classifier: Development Status :: 3 - Alpha Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: BSD License Classifier: Programming Language :: Python :: 3 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 Classifier: Topic :: Desktop Environment Classifier: Topic :: Software Development Classifier: Topic :: Software Development :: User Interfaces Requires-Python: >=3.8 Requires-Dist: in-n-out>=0.1.5 Requires-Dist: psygnal>=0.3.4 Requires-Dist: pydantic>=1.8 Requires-Dist: typing-extensions Provides-Extra: dev Requires-Dist: black; extra == 'dev' Requires-Dist: ipython; extra == 'dev' Requires-Dist: isort; extra == 'dev' Requires-Dist: mypy; extra == 'dev' Requires-Dist: pdbpp; extra == 'dev' Requires-Dist: pre-commit; extra == 'dev' Requires-Dist: pydocstyle; extra == 'dev' Requires-Dist: pytest; extra == 'dev' Requires-Dist: pytest-cov; extra == 'dev' Requires-Dist: rich; extra == 'dev' Provides-Extra: docs Requires-Dist: griffe==0.22.0; extra == 'docs' Requires-Dist: mkdocs-macros-plugin==0.7.0; extra == 'docs' Requires-Dist: mkdocs-material~=8.3; extra == 'docs' Requires-Dist: mkdocs-minify-plugin==0.5.0; extra == 'docs' Requires-Dist: mkdocs==1.3.0; extra == 'docs' Requires-Dist: mkdocstrings-python==0.7.0; extra == 'docs' Requires-Dist: mkdocstrings==0.19.0; extra == 'docs' Requires-Dist: typing-extensions>=4.0; extra == 'docs' Provides-Extra: qt Requires-Dist: qtpy; extra == 'qt' Requires-Dist: superqt; extra == 'qt' Provides-Extra: test Requires-Dist: pytest-cov; extra == 'test' Requires-Dist: pytest>=6.0; extra == 'test' Provides-Extra: test-qt Requires-Dist: fonticon-fontawesome6; extra == 'test-qt' Requires-Dist: pytest-qt; extra == 'test-qt' Description-Content-Type: text/markdown # 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/