diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e42e1211..881a0179 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,60 +18,75 @@ jobs: check-manifest: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - run: pipx run check-manifest + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.1.0 + with: + enable-cache: true + python-version: "3.13" + - run: uv run prek run --all-files + pyright: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: astral-sh/setup-uv@v7 + - uses: actions/checkout@v7 + - uses: astral-sh/setup-uv@v10.1.0 + with: + enable-cache: true + python-version: "3.13" - run: uv run --with pyqt6 pyright test: - name: ${{ matrix.platform }} py${{ matrix.python-version }} + name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.resolution }} runs-on: ${{ matrix.platform }} env: UV_PRERELEASE: ${{ github.event_name == 'schedule' && 'allow' || 'if-necessary-or-explicit' }} - UV_NO_SYNC: 1 + UV_RESOLUTION: ${{ matrix.resolution }} + PYTEST_ADDOPTS: ${{ matrix.resolution == 'lowest-direct' && '-W ignore' || '' }} strategy: fail-fast: false matrix: - python-version: ["3.10", "3.12", "3.14"] + python-version: ["3.11", "3.13", "3.15"] platform: [ubuntu-latest, macos-latest, windows-latest] + resolution: [highest] include: + - python-version: "3.12" + platform: macos-latest + resolution: highest + - python-version: "3.14" + platform: macos-latest + resolution: highest - python-version: "3.11" - platform: "macos-latest" - - python-version: "3.13" - platform: "macos-latest" - - python-version: "3.10" - platform: "ubuntu-latest" - resolution: "lowest-direct" + platform: ubuntu-latest + resolution: lowest-direct + - python-version: "3.15" + platform: ubuntu-latest + resolution: lowest-direct steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: ๐Ÿ Set up Python ${{ matrix.python-version }} - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v10.1.0 with: python-version: ${{ matrix.python-version }} enable-cache: true cache-dependency-glob: "**/pyproject.toml" - - name: Install Dependencies - run: uv sync --no-dev --group test --resolution ${{ matrix.resolution || 'highest'}} - - name: ๐Ÿงช Run Tests - run: uv run coverage run -p -m pytest -v - env: - PYTEST_ADDOPTS: ${{ matrix.resolution == 'lowest-direct' && '-W ignore' || '' }} + run: uv run --no-dev --group test coverage run -p -m pytest -v - name: Upload coverage - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: - name: covreport-${{ matrix.platform }}-py${{ matrix.python-version }} + name: covreport-${{ matrix.platform }}-py${{ matrix.python-version }}-${{ matrix.resolution }} path: ./.coverage* include-hidden-files: true @@ -80,38 +95,34 @@ jobs: runs-on: ${{ matrix.platform }} env: UV_PRERELEASE: ${{ github.event_name == 'schedule' && 'allow' || 'if-necessary-or-explicit' }} - UV_NO_SYNC: 1 + UV_RESOLUTION: ${{ matrix.resolution }} + PYTEST_ADDOPTS: ${{ matrix.resolution == 'lowest-direct' && '-W ignore' || '' }} strategy: fail-fast: false matrix: - python-version: ["3.10", "3.14"] + python-version: ["3.11", "3.15"] platform: [macos-latest, windows-latest] extra: [pyside6, pyqt6] resolution: [highest, lowest-direct] steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - name: ๐Ÿ Set up Python ${{ matrix.python-version }} - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@v10.1.0 with: python-version: ${{ matrix.python-version }} enable-cache: true cache-dependency-glob: "**/pyproject.toml" - - uses: pyvista/setup-headless-display-action@v4 + - uses: pyvista/setup-headless-display-action@v5 with: qt: true - - name: Install Dependencies - run: uv sync --no-dev --group test-qt --extra ${{ matrix.extra }} --resolution ${{ matrix.resolution }} - - name: ๐Ÿงช Run Tests - run: uv run coverage run -p -m pytest -v - env: - PYTEST_ADDOPTS: ${{ matrix.resolution == 'lowest-direct' && '-W ignore' || '' }} + run: uv run --no-dev --group test-qt --extra ${{ matrix.extra }} coverage run -p -m pytest -v # If something goes wrong with --pre tests, we can open an issue in the repo - name: ๐Ÿ“ Report --pre Failures @@ -128,7 +139,7 @@ jobs: update_existing: true - name: Upload coverage - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@v7 with: name: covreport-${{ matrix.platform }}-py${{ matrix.python-version }}-${{ matrix.extra }}-${{ matrix.resolution }} path: ./.coverage* @@ -158,10 +169,10 @@ jobs: needs: [check-manifest, test] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: hynek/build-and-inspect-python-package@v2 + - uses: hynek/build-and-inspect-python-package@v3.0.1 upload-to-pypi: name: Upload package to PyPI @@ -174,13 +185,13 @@ jobs: steps: - name: Download built artifact to dist/ - uses: actions/download-artifact@v7 + uses: actions/download-artifact@v8 with: name: Packages path: dist - name: ๐Ÿšข Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - - uses: softprops/action-gh-release@v2 + - uses: softprops/action-gh-release@v3 with: generate_release_notes: true files: "./dist/*" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index fbe85e22..e9c03390 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -19,10 +19,10 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 - - uses: astral-sh/setup-uv@v7 + - uses: astral-sh/setup-uv@v10.1.0 with: python-version: "3.13" enable-cache: true @@ -40,6 +40,6 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest steps: - - uses: actions/configure-pages@v5 + - uses: actions/configure-pages@v6 - uses: actions/deploy-pages@v5 id: deployment diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ad4d8ffe..edfce94b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,25 +5,31 @@ ci: repos: - repo: https://github.com/abravalheri/validate-pyproject - rev: v0.25 + rev: "0.26" hooks: - id: validate-pyproject + - repo: https://github.com/rhysd/actionlint + rev: v1.7.12 + hooks: + - id: actionlint + files: "^\\.github/workflows/.*\\.ya?ml$" + - repo: https://github.com/adhtruong/mirrors-typos - rev: v1.46.0 + rev: v1.50.2 hooks: - id: typos args: [--force-exclude] - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.12 + rev: v0.16.8 hooks: - id: ruff-check args: ["--fix", "--unsafe-fixes"] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.20.2 + rev: v2.3.1 hooks: - id: mypy files: "^src/" @@ -40,4 +46,4 @@ repos: exclude: "^tests/.*|^demo/.*|^docs/.*" types_or: [python, pyi] require_serial: true - entry: uv run pyright + entry: uv run --with pyqt6 pyright diff --git a/demo/multi_file/constants.py b/demo/multi_file/constants.py index 85d77e02..5e4f9244 100644 --- a/demo/multi_file/constants.py +++ b/demo/multi_file/constants.py @@ -1,7 +1,7 @@ -from enum import Enum +from enum import StrEnum -class CommandId(str, Enum): +class CommandId(StrEnum): OPEN = "myapp.open" CLOSE = "myapp.close" SAVE = "myapp.save" @@ -17,7 +17,7 @@ def __str__(self) -> str: return self.value -class MenuId(str, Enum): +class MenuId(StrEnum): FILE = "myapp/file" EDIT = "myapp/edit" diff --git a/docs/getting_started.md b/docs/getting_started.md index 6c71708d..69c62ba3 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -8,7 +8,7 @@ object. ```python from app_model import Application -my_app = Application('my-app') +my_app = Application("my-app") ``` ## Registering Actions @@ -27,27 +27,30 @@ and additional metadata like title, icons, tooltips, etc... ```python from app_model.types import Action, KeyBindingRule, KeyCode, KeyMod, MenuRule + def open_file(): - print('open file!') + print("open file!") + def close_window(): - print('close window!') + print("close window!") + ACTIONS: list[Action] = [ Action( - id='open', + id="open", title="Open", icon="fa6-solid:folder-open", callback=open_file, - menus=['File'], + menus=["File"], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyO)], ), Action( - id='close', + id="close", title="Close", icon="fa-solid:window-close", callback=close_window, - menus=['File'], + menus=["File"], keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyW)], ), # ... @@ -105,9 +108,10 @@ You can connect callbacks to these events to handle them as needed. ```python @my_app.commands.registered.connect def on_command_registered(command_id: str): - print(f'Command {command_id!r} registered!') + print(f"Command {command_id!r} registered!") -my_app.commands.register_command('new-id', lambda: None, title='No-op') + +my_app.commands.register_command("new-id", lambda: None, title="No-op") # Command 'new-id' registered! ``` @@ -116,7 +120,7 @@ my_app.commands.register_command('new-id', lambda: None, title='No-op') Registered commands may be executed on-demand using [`execute_command`][app_model.registries.CommandsRegistry.execute_command] method on the command registry: ```python -my_app.commands.execute_command('open') +my_app.commands.execute_command("open") # prints "open file!" from the `open_file` function registered above. ``` @@ -150,7 +154,7 @@ method: ```python class User: def name(self): - return 'John Doe' + return "John Doe" ``` Assume the application has some way of retrieving the current user: @@ -174,14 +178,15 @@ for callbacks in actions registered with the application. def print_user_name(user: User) -> None: print(f"Hi {user.name()}!") + action = Action( - id='greet', + id="greet", title="Greet Current User", callback=print_user_name, ) my_app.register_action(action) -my_app.commands.execute_command('greet') +my_app.commands.execute_command("greet") # prints "Hi John Doe!" ``` @@ -215,12 +220,12 @@ main = QModelMainWindow(my_app) # pick menus for main menu bar, # using menu ids from the application's MenusRegistry -main.setModelMenuBar(['File']) +main.setModelMenuBar(["File"]) # add toolbars using menu ids from the application's MenusRegistry # here we re-use the File menu ... but you can have menus # dedicated for toolbars, or just exclude items from the menu -main.addModelToolBar('File') +main.addModelToolBar("File") main.show() app.exec() diff --git a/pyproject.toml b/pyproject.toml index 07536bdd..9ab63c5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ source = "vcs" name = "app-model" description = "Generic application schema implemented in python" readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" license = { text = "BSD 3-Clause License" } authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }] classifiers = [ @@ -20,11 +20,11 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: BSD License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Topic :: Desktop Environment", "Topic :: Software Development", "Topic :: Software Development :: User Interfaces", @@ -34,9 +34,12 @@ dynamic = ["version"] dependencies = [ "psygnal>=0.10", "pydantic>=2.8", + # first version with 3.15 wheels for pydantic-core (drop `b1` once 2.14 is out) + "pydantic>=2.14.0b1; python_version >= '3.15'", "in-n-out>=0.1.5", "typing_extensions>=4.12", "pydantic-core>=2.35; python_version >= '3.14'", + "pydantic-core>=2.48; python_version >= '3.15'", ] [project.urls] @@ -80,7 +83,7 @@ app-model = { workspace = true } # https://docs.astral.sh/ruff [tool.ruff] line-length = 88 -target-version = "py310" +target-version = "py311" src = ["src", "tests"] fix = true unsafe-fixes = true diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index f01b6495..6e96c07d 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -16,10 +16,10 @@ if TYPE_CHECKING: from collections.abc import Mapping + from typing import Self from PyQt6.QtGui import QAction from qtpy.QtCore import QObject - from typing_extensions import Self from app_model.types import CommandRule, MenuItem else: diff --git a/src/app_model/expressions/_expressions.py b/src/app_model/expressions/_expressions.py index adfa7ca1..04594210 100644 --- a/src/app_model/expressions/_expressions.py +++ b/src/app_model/expressions/_expressions.py @@ -15,7 +15,7 @@ overload, ) -ConstType: TypeAlias = None | str | bytes | bool | int | float +ConstType: TypeAlias = str | bytes | bool | int | float | None PassedType = TypeVar( "PassedType", bound=ast.cmpop | ast.operator | ast.boolop | ast.unaryop | ast.expr_context, diff --git a/tests/test_qt/test_qactions.py b/tests/test_qt/test_qactions.py index 034d013c..6d3cb2eb 100644 --- a/tests/test_qt/test_qactions.py +++ b/tests/test_qt/test_qactions.py @@ -96,19 +96,20 @@ def test_icon_follows_theme(qapp, simple_app: "Application") -> None: simple_app.theme_mode = "dark" ico = q_action.icon() img = ico.pixmap(16, 16).toImage() - color_center = QColor(img.pixel(8, 8)) + dpr = int(img.devicePixelRatio()) # pixmap is scaled on HiDPI screens + color_center = QColor(img.pixel(8 * dpr, 8 * dpr)) assert color_center.name() == "#ff0000" # circle icon should leave some black on the corners - color_corner = QColor(img.pixel(1, 1)) + color_corner = QColor(img.pixel(dpr, dpr)) assert color_corner.name() == "#000000" simple_app.theme_mode = "light" ico = q_action.icon() img = ico.pixmap(16, 16).toImage() - color_center = QColor(img.pixel(8, 8)) + color_center = QColor(img.pixel(8 * dpr, 8 * dpr)) assert color_center.name() == "#0000ff" - # quare icons goes to the corners and blends color, resulting in non-black - color_corner = QColor(img.pixel(1, 1)) + # square icons goes to the corners and blends color, resulting in non-black + color_corner = QColor(img.pixel(dpr, dpr)) assert color_corner.name() != "#000000" with pytest.raises(ValueError):