app_model-0.2.0/.github_changelog_generator 0000644 0000000 0000000 00000000421 13615410400 015726 0 ustar 00 user=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.yaml 0000644 0000000 0000000 00000002371 13615410400 014655 0 ustar 00 ci:
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.yaml 0000644 0000000 0000000 00000000450 13615410400 013617 0 ustar 00 # 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.md 0000644 0000000 0000000 00000027510 13615410400 012207 0 ustar 00 # 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.yml 0000644 0000000 0000000 00000000103 13615410400 012530 0 ustar 00 coverage:
status:
patch:
default:
target: 100%
app_model-0.2.0/mkdocs.yml 0000644 0000000 0000000 00000002167 13615410400 012402 0 ustar 00 site_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.py 0000644 0000000 0000000 00000001232 13615410400 012101 0 ustar 00 import 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.md 0000644 0000000 0000000 00000000500 13615410400 014431 0 ustar 00 * 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.md 0000644 0000000 0000000 00000000611 13615410400 015116 0 ustar 00 ---
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.yml 0000644 0000000 0000000 00000000424 13615410400 014561 0 ustar 00 # 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.yml 0000644 0000000 0000000 00000011505 13615410400 015106 0 ustar 00 name: 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.yml 0000644 0000000 0000000 00000003412 13615410400 015452 0 ustar 00 name: --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.py 0000644 0000000 0000000 00000000375 13615410400 015356 0 ustar 00 import 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.py 0000644 0000000 0000000 00000020013 13615410400 013623 0 ustar 00 from 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.py 0000644 0000000 0000000 00000020174 13615410400 014357 0 ustar 00 # 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__.py 0000644 0000000 0000000 00000000000 13615410400 015545 0 ustar 00 app_model-0.2.0/demo/multi_file/__main__.py 0000644 0000000 0000000 00000000427 13615410400 015543 0 ustar 00 import 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.py 0000644 0000000 0000000 00000004076 13615410400 015467 0 ustar 00 from 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.py 0000644 0000000 0000000 00000001201 13615410400 014572 0 ustar 00 from 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.py 0000644 0000000 0000000 00000000714 13615410400 016036 0 ustar 00 from 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.py 0000644 0000000 0000000 00000000574 13615410400 016036 0 ustar 00 from 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.py 0000644 0000000 0000000 00000003617 13615410400 013325 0 ustar 00 import 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.md 0000644 0000000 0000000 00000000124 13615410400 014143 0 ustar 00 # Application
::: app_model.Application
options:
show_signature: false
app_model-0.2.0/docs/expressions.md 0000644 0000000 0000000 00000000467 13615410400 014234 0 ustar 00 # 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.md 0000644 0000000 0000000 00000010577 13615410400 012764 0 ustar 00 # 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.md 0000644 0000000 0000000 00000000302 13615410400 014144 0 ustar 00 # 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.md 0000644 0000000 0000000 00000000214 13615410400 014020 0 ustar 00 # Registries
::: app_model.registries.CommandsRegistry
::: app_model.registries.KeyBindingsRegistry
::: app_model.registries.MenusRegistry
app_model-0.2.0/docs/types.md 0000644 0000000 0000000 00000001100 13615410400 012777 0 ustar 00 # 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__.py 0000644 0000000 0000000 00000000541 13615410400 015231 0 ustar 00 """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.py 0000644 0000000 0000000 00000014675 13615410400 014426 0 ustar 00 from __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.py 0000644 0000000 0000000 00000002473 13615410400 017015 0 ustar 00 from __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.typed 0000644 0000000 0000000 00000000000 13615410400 014605 0 ustar 00 app_model-0.2.0/src/app_model/backends/__init__.py 0000644 0000000 0000000 00000000171 13615410400 017002 0 ustar 00 """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__.py 0000644 0000000 0000000 00000001465 13615410400 017435 0 ustar 00 """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.py 0000644 0000000 0000000 00000014130 13615410400 017464 0 ustar 00 from __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.py 0000644 0000000 0000000 00000001256 13615410400 021205 0 ustar 00 from 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.py 0000644 0000000 0000000 00000040004 13615410400 017474 0 ustar 00 import 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.py 0000644 0000000 0000000 00000002750 13615410400 020370 0 ustar 00 from __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.py 0000644 0000000 0000000 00000031045 13615410400 017157 0 ustar 00 from __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.py 0000644 0000000 0000000 00000000677 13615410400 017016 0 ustar 00 from __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__.py 0000644 0000000 0000000 00000001214 13615410400 017611 0 ustar 00 """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.py 0000644 0000000 0000000 00000011347 13615410400 017705 0 ustar 00 from __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.py 0000644 0000000 0000000 00000016024 13615410400 020735 0 ustar 00 from __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.py 0000644 0000000 0000000 00000046171 13615410400 020606 0 ustar 00 """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__.py 0000644 0000000 0000000 00000000533 13615410400 017412 0 ustar 00 """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.py 0000644 0000000 0000000 00000015051 13615410400 020451 0 ustar 00 from __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.py 0000644 0000000 0000000 00000005113 13615410400 021154 0 ustar 00 from __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.py 0000644 0000000 0000000 00000011340 13615410400 017774 0 ustar 00 from __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.py 0000644 0000000 0000000 00000021074 13615410400 017641 0 ustar 00 from __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__.py 0000644 0000000 0000000 00000001634 13615410400 016401 0 ustar 00 """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.py 0000644 0000000 0000000 00000004466 13615410400 016264 0 ustar 00 from __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.py 0000644 0000000 0000000 00000001044 13615410400 015706 0 ustar 00 from 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.py 0000644 0000000 0000000 00000005730 13615410400 017447 0 ustar 00 from 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.py 0000644 0000000 0000000 00000002131 13615410400 017006 0 ustar 00 import 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.py 0000644 0000000 0000000 00000003046 13615410400 015730 0 ustar 00 from 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.py 0000644 0000000 0000000 00000005344 13615410400 020155 0 ustar 00 from 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.py 0000644 0000000 0000000 00000007752 13615410400 017003 0 ustar 00 from 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.py 0000644 0000000 0000000 00000003332 13615410400 016136 0 ustar 00 import 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__.py 0000644 0000000 0000000 00000000520 13615410400 017504 0 ustar 00 from ._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.py 0000644 0000000 0000000 00000102435 13615410400 020061 0 ustar 00 from 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.py 0000644 0000000 0000000 00000023356 13615410400 020426 0 ustar 00 import 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.py 0000644 0000000 0000000 00000017167 13615410400 021600 0 ustar 00 from 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.py 0000644 0000000 0000000 00000015737 13615410400 013747 0 ustar 00 import 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.py 0000644 0000000 0000000 00000007230 13615410400 014606 0 ustar 00 from 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.py 0000644 0000000 0000000 00000006257 13615410400 013736 0 ustar 00 from __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.py 0000644 0000000 0000000 00000002356 13615410400 016520 0 ustar 00 import 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.py 0000644 0000000 0000000 00000002360 13615410400 015112 0 ustar 00 import 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.py 0000644 0000000 0000000 00000006744 13615410400 015465 0 ustar 00 import 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.py 0000644 0000000 0000000 00000001156 13615410400 015327 0 ustar 00 from 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.py 0000644 0000000 0000000 00000001231 13615410400 014305 0 ustar 00 import 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.py 0000644 0000000 0000000 00000000225 13615410400 016230 0 ustar 00 from 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.py 0000644 0000000 0000000 00000004523 13615410400 017357 0 ustar 00 import 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.py 0000644 0000000 0000000 00000003600 13615410400 020405 0 ustar 00 import 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.py 0000644 0000000 0000000 00000015520 13615410400 020254 0 ustar 00 import 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__.py 0000644 0000000 0000000 00000000172 13615410400 015327 0 ustar 00 import 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.py 0000644 0000000 0000000 00000000604 13615410400 015736 0 ustar 00 import 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.py 0000644 0000000 0000000 00000002601 13615410400 016447 0 ustar 00 from 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.py 0000644 0000000 0000000 00000000632 13615410400 020002 0 ustar 00 from 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.py 0000644 0000000 0000000 00000015535 13615410400 016307 0 ustar 00 from 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.py 0000644 0000000 0000000 00000001243 13615410400 017164 0 ustar 00 from 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.py 0000644 0000000 0000000 00000012602 13615410400 015755 0 ustar 00 from __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/.gitignore 0000644 0000000 0000000 00000002336 13615410400 012365 0 ustar 00 # 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/LICENSE 0000644 0000000 0000000 00000002751 13615410400 011403 0 ustar 00 BSD 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.md 0000644 0000000 0000000 00000002022 13615410400 011644 0 ustar 00 # app-model
[](https://github.com/pyapp-kit/app-model/raw/main/LICENSE)
[](https://pypi.org/project/app-model)
[](https://python.org)
[](https://github.com/pyapp-kit/app-model/actions/workflows/ci.yml)
[](https://codecov.io/gh/pyapp-kit/app-model)
[](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.toml 0000644 0000000 0000000 00000010666 13615410400 013316 0 ustar 00 # 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-INFO 0000644 0000000 0000000 00000006343 13615410400 011474 0 ustar 00 Metadata-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
[](https://github.com/pyapp-kit/app-model/raw/main/LICENSE)
[](https://pypi.org/project/app-model)
[](https://python.org)
[](https://github.com/pyapp-kit/app-model/actions/workflows/ci.yml)
[](https://codecov.io/gh/pyapp-kit/app-model)
[](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/