From b36a40a01f884dc6b5ff99afa0d112038ed4fa73 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 6 May 2024 11:01:46 -0400 Subject: [PATCH 1/8] feat: move control of action registration --- src/app_model/registries/_commands_reg.py | 22 ++++++++- src/app_model/registries/_keybindings_reg.py | 43 ++++++++++++++++- src/app_model/registries/_menus_reg.py | 51 ++++++++++++++++++-- src/app_model/registries/_register.py | 40 +++------------ 4 files changed, 118 insertions(+), 38 deletions(-) diff --git a/src/app_model/registries/_commands_reg.py b/src/app_model/registries/_commands_reg.py index fb706f7f..b2e6eb0e 100644 --- a/src/app_model/registries/_commands_reg.py +++ b/src/app_model/registries/_commands_reg.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from typing_extensions import ParamSpec - from app_model.types import DisposeCallable + from app_model.types import DisposeCallable, Action P = ParamSpec("P") else: @@ -90,6 +90,26 @@ def __init__( self._injection_store = injection_store self._raise_synchronous_exceptions = raise_synchronous_exceptions + def register_action( + self, action: Action + ) -> DisposeCallable: + """Register an Action object. + + This is a convenience method that registers the action's callback + with the action's ID and title using `register_command`. + + Parameters + ---------- + action: Action + Action to register + + Returns + ------- + DisposeCallable + A function that can be called to unregister the action. + """ + return self.register_command(action.id, action.callback, action.title) + def register_command( self, id: str, callback: Callable[P, R] | str, title: str ) -> DisposeCallable: diff --git a/src/app_model/registries/_keybindings_reg.py b/src/app_model/registries/_keybindings_reg.py index 0f586588..175d011c 100644 --- a/src/app_model/registries/_keybindings_reg.py +++ b/src/app_model/registries/_keybindings_reg.py @@ -10,7 +10,7 @@ from typing import Iterator, TypeVar from app_model import expressions - from app_model.types import DisposeCallable, KeyBindingRule + from app_model.types import Action, DisposeCallable, KeyBindingRule CommandDecorator = Callable[[Callable], Callable] CommandCallable = TypeVar("CommandCallable", bound=Callable) @@ -33,6 +33,47 @@ class KeyBindingsRegistry: def __init__(self) -> None: self._keybindings: list[_RegisteredKeyBinding] = [] + def register_action_keybindings(self, action: Action) -> DisposeCallable | None: + """Register all keybindings declared in `action.keybindings`. + + Parameters + ---------- + action : Action + The action to register keybindings for. + + Returns + ------- + DisposeCallable | None + A function that can be called to unregister the keybindings. If no + keybindings were registered, returns None. + """ + if not (keybindings := action.keybindings): + return None + + disposers: list[Callable[[], None]] = [] + for keyb in keybindings: + if action.enablement is not None: + kwargs = keyb.model_dump() + kwargs["when"] = ( + action.enablement + if keyb.when is None + else action.enablement | keyb.when + ) + _keyb = type(keyb)(**kwargs) + else: + _keyb = keyb + if d := self.register_keybinding_rule(action.id, _keyb): + disposers.append(d) + + if not disposers: + return None + + def _dispose() -> None: + for disposer in disposers: + disposer() + + return _dispose + def register_keybinding_rule( self, id: str, rule: KeyBindingRule ) -> DisposeCallable | None: diff --git a/src/app_model/registries/_menus_reg.py b/src/app_model/registries/_menus_reg.py index 9d041e9e..8e1dde43 100644 --- a/src/app_model/registries/_menus_reg.py +++ b/src/app_model/registries/_menus_reg.py @@ -1,13 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Callable, Final, Iterable, Iterator, Sequence +from typing import TYPE_CHECKING, Any, Callable, Final, Iterable, Iterator from psygnal import Signal from app_model.types import MenuItem if TYPE_CHECKING: - from app_model.types import DisposeCallable, MenuOrSubmenu + from app_model.types import Action, DisposeCallable, MenuOrSubmenu MenuId = str @@ -21,14 +21,57 @@ class MenusRegistry: def __init__(self) -> None: self._menu_items: dict[MenuId, dict[MenuOrSubmenu, None]] = {} + def append_action_menus(self, action: Action) -> DisposeCallable | None: + """Append all MenuRule items declared in `action.menus`. + + Parameters + ---------- + action : Action + The action containing menus to append. + + Returns + ------- + DisposeCallable | None + A function that can be called to unregister the menu items. If no + menu items were registered, returns `None`. + """ + if not (menu_rules := action.menus): + return None + + disposers: list[Callable[[], None]] = [] + disp1 = self.append_menu_items( + ( + rule.id, + MenuItem( + command=action, when=rule.when, group=rule.group, order=rule.order + ), + ) + for rule in menu_rules + ) + disposers.append(disp1) + + if action.palette: + menu_item = MenuItem(command=action, when=action.enablement) + disp = self.append_menu_items([(self.COMMAND_PALETTE_ID, menu_item)]) + disposers.append(disp) + + if not disposers: + return None + + def _dispose() -> None: + for disposer in disposers: + disposer() + + return _dispose + def append_menu_items( - self, items: Sequence[tuple[MenuId, MenuOrSubmenu]] + self, items: Iterable[tuple[MenuId, MenuOrSubmenu]] ) -> DisposeCallable: """Append menu items to the registry. Parameters ---------- - items : Sequence[Tuple[str, MenuOrSubmenu]] + items : Iterable[Tuple[str, MenuOrSubmenu]] Items to append. Returns diff --git a/src/app_model/registries/_register.py b/src/app_model/registries/_register.py index 246e2266..0c01f66e 100644 --- a/src/app_model/registries/_register.py +++ b/src/app_model/registries/_register.py @@ -270,38 +270,14 @@ def _register_action_obj(app: Application | str, action: Action) -> DisposeCalla 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 = keyb.model_dump() - 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) + # commands + disposers = [app.commands.register_action(action)] + # menus + if dm := app.menus.append_action_menus(action): + disposers.append(dm) + # keybindings + if dk := app.keybindings.register_action_keybindings(action): + disposers.append(dk) def _dispose() -> None: for d in disposers: From f672a5f6c24cd66ef3e844e02b92a2418a723aa8 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 6 May 2024 11:02:04 -0400 Subject: [PATCH 2/8] lint --- src/app_model/registries/_commands_reg.py | 6 ++---- src/app_model/registries/_register.py | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/app_model/registries/_commands_reg.py b/src/app_model/registries/_commands_reg.py index b2e6eb0e..126b2162 100644 --- a/src/app_model/registries/_commands_reg.py +++ b/src/app_model/registries/_commands_reg.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: from typing_extensions import ParamSpec - from app_model.types import DisposeCallable, Action + from app_model.types import Action, DisposeCallable P = ParamSpec("P") else: @@ -90,9 +90,7 @@ def __init__( self._injection_store = injection_store self._raise_synchronous_exceptions = raise_synchronous_exceptions - def register_action( - self, action: Action - ) -> DisposeCallable: + def register_action(self, action: Action) -> DisposeCallable: """Register an Action object. This is a convenience method that registers the action's callback diff --git a/src/app_model/registries/_register.py b/src/app_model/registries/_register.py index 0c01f66e..1712d140 100644 --- a/src/app_model/registries/_register.py +++ b/src/app_model/registries/_register.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, overload -from app_model.types import Action, MenuItem +from app_model.types import Action if TYPE_CHECKING: from typing import Any, Callable, Literal, TypeVar From cc3660d078c1ee158c6bed778489572f6f78540a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 6 May 2024 11:39:21 -0400 Subject: [PATCH 3/8] fix test --- src/app_model/registries/_menus_reg.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/app_model/registries/_menus_reg.py b/src/app_model/registries/_menus_reg.py index 8e1dde43..619bfc56 100644 --- a/src/app_model/registries/_menus_reg.py +++ b/src/app_model/registries/_menus_reg.py @@ -35,9 +35,6 @@ def append_action_menus(self, action: Action) -> DisposeCallable | None: A function that can be called to unregister the menu items. If no menu items were registered, returns `None`. """ - if not (menu_rules := action.menus): - return None - disposers: list[Callable[[], None]] = [] disp1 = self.append_menu_items( ( @@ -46,7 +43,7 @@ def append_action_menus(self, action: Action) -> DisposeCallable | None: command=action, when=rule.when, group=rule.group, order=rule.order ), ) - for rule in menu_rules + for rule in action.menus or () ) disposers.append(disp1) From aa081abe4cf6a85d64cc814e8c905cff8ecfc4a5 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 6 May 2024 15:10:44 -0400 Subject: [PATCH 4/8] pragma and update ci --- .github/workflows/ci.yml | 27 +++++++++++--------- src/app_model/registries/_keybindings_reg.py | 2 +- src/app_model/registries/_menus_reg.py | 2 +- 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c76f1b4..7b391a97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,14 +15,14 @@ concurrency: jobs: test: - uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v1 + uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 secrets: inherit with: os: ${{ matrix.os }} python-version: ${{ matrix.python-version }} pip-post-installs: ${{ matrix.pydantic }} pip-install-pre-release: ${{ github.event_name == 'schedule' }} - report-failures: ${{ github.event_name == 'schedule' }} + coverage-upload: artifact strategy: fail-fast: false matrix: @@ -47,7 +47,7 @@ jobs: pydantic: "'pydantic<2'" test-qt: - uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v1 + uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2 secrets: inherit with: qt: ${{ matrix.qt }} @@ -55,7 +55,7 @@ jobs: python-version: ${{ matrix.python-version }} extras: test-qt pip-install-pre-release: ${{ github.event_name == 'schedule' }} - report-failures: ${{ github.event_name == 'schedule' }} + coverage-upload: artifact strategy: fail-fast: false matrix: @@ -69,7 +69,6 @@ jobs: - python-version: "3.10" os: "ubuntu-latest" qt: "PySide2~=5.15.0" - - python-version: "3.10" os: "ubuntu-latest" qt: "PyQt6~=6.2.0" @@ -85,11 +84,9 @@ jobs: - python-version: "3.11" os: "ubuntu-latest" qt: "PySide6~=6.6.0" - # not working until pyqt6-qt is fixed - # - python-version: "3.11" - # os: "ubuntu-latest" - # qt: "PyQt6~=6.6.0" - + - python-version: "3.11" + os: "ubuntu-latest" + qt: pyqt6 - python-version: "3.10" os: "windows-latest" qt: "PySide2" @@ -97,8 +94,14 @@ jobs: os: "macos-13" qt: "PySide2" + upload_coverage: + if: always() + needs: [test, test-qt] + uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2 + secrets: inherit + test_napari: - uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v1 + uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2 with: dependency-repo: napari/napari dependency-ref: ${{ matrix.napari-version }} @@ -106,7 +109,7 @@ jobs: qt: ${{ matrix.qt }} pytest-args: 'napari/_qt/_qapp_model napari/_app_model napari/utils/_tests/test_key_bindings.py -k "not async and not qt_dims_2"' python-version: "3.10" - post-install-cmd: "pip install lxml_html_clean" # fix for napari v0.4.19 + post-install-cmd: "pip install lxml_html_clean" # fix for napari v0.4.19 strategy: fail-fast: false matrix: diff --git a/src/app_model/registries/_keybindings_reg.py b/src/app_model/registries/_keybindings_reg.py index 175d011c..6549c983 100644 --- a/src/app_model/registries/_keybindings_reg.py +++ b/src/app_model/registries/_keybindings_reg.py @@ -65,7 +65,7 @@ def register_action_keybindings(self, action: Action) -> DisposeCallable | None: if d := self.register_keybinding_rule(action.id, _keyb): disposers.append(d) - if not disposers: + if not disposers: # pragma: no cover return None def _dispose() -> None: diff --git a/src/app_model/registries/_menus_reg.py b/src/app_model/registries/_menus_reg.py index 619bfc56..5134e372 100644 --- a/src/app_model/registries/_menus_reg.py +++ b/src/app_model/registries/_menus_reg.py @@ -52,7 +52,7 @@ def append_action_menus(self, action: Action) -> DisposeCallable | None: disp = self.append_menu_items([(self.COMMAND_PALETTE_ID, menu_item)]) disposers.append(disp) - if not disposers: + if not disposers: # pragma: no cover return None def _dispose() -> None: From 2136773c995e76a10d69148d3b4ec6984716d43a Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 6 May 2024 15:26:57 -0400 Subject: [PATCH 5/8] expose RegisteredCommand and make immutable --- src/app_model/registries/__init__.py | 3 +- src/app_model/registries/_commands_reg.py | 62 +++++++++++++++++------ 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/app_model/registries/__init__.py b/src/app_model/registries/__init__.py index 2a10441b..39a03381 100644 --- a/src/app_model/registries/__init__.py +++ b/src/app_model/registries/__init__.py @@ -1,6 +1,6 @@ """App-model registries, such as menus, keybindings, commands.""" -from ._commands_reg import CommandsRegistry +from ._commands_reg import CommandsRegistry, RegisteredCommand from ._keybindings_reg import KeyBindingsRegistry from ._menus_reg import MenusRegistry from ._register import register_action @@ -10,4 +10,5 @@ "KeyBindingsRegistry", "MenusRegistry", "register_action", + "RegisteredCommand", ] diff --git a/src/app_model/registries/_commands_reg.py b/src/app_model/registries/_commands_reg.py index 126b2162..5a528982 100644 --- a/src/app_model/registries/_commands_reg.py +++ b/src/app_model/registries/_commands_reg.py @@ -1,7 +1,6 @@ from __future__ import annotations from concurrent.futures import Future, ThreadPoolExecutor -from functools import cached_property from typing import TYPE_CHECKING, Any, Callable, Generic, Iterator, TypeVar, cast from in_n_out import Store @@ -26,15 +25,26 @@ R = TypeVar("R") -class _RegisteredCommand(Generic[P, R]): +class RegisteredCommand(Generic[P, R]): """Small object to represent a command in the CommandsRegistry. - Only used internally by the CommandsRegistry. + 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` """ + __slots__ = ( + "id", + "callback", + "title", + "_resolved_callback", + "_injection_store", + "_injected_callback", + "_initialized", + ) + def __init__( self, id: str, @@ -45,35 +55,57 @@ def __init__( 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() + self._resolved_callback = callback if callable(callback) else None + self._injected_callback: Callable[P, R] | None = None + self._initialized = True + + def __setattr__(self, name: str, value: Any) -> None: + """Object is immutable after initialization.""" + if getattr(self, "_initialized", False): + raise AttributeError( + f"Cannot set attribute {name!r} on {self.__class__.__name__} instance" + ) + super().__setattr__(name, value) @property def resolved_callback(self) -> Callable[P, R]: + """Return the resolved command callback. + + This property is cached, so the callback types are only resolved once. + """ if self._resolved_callback is None: from app_model.types._utils import import_python_name try: - self._resolved_callback = import_python_name(str(self.callback)) + cb = import_python_name(str(self.callback)) except ImportError as e: - self._resolved_callback = cast(Callable[P, R], lambda *a, **k: None) + object.__setattr__(self, "_resolved_callback", lambda *a, **k: None) raise type(e)( f"Command pointer {self.callback!r} registered for Command " f"{self.id!r} was not importable: {e}" ) from e - if not callable(self._resolved_callback): + if not callable(cb): # don't try to import again, just create a no-op - self._resolved_callback = cast(Callable[P, R], lambda *a, **k: None) + object.__setattr__(self, "_resolved_callback", lambda *a, **k: None) raise TypeError( f"Command pointer {self.callback!r} registered for Command " f"{self.id!r} did not resolve to a callble object." ) - return self._resolved_callback + object.__setattr__(self, "_resolved_callback", cb) + return cast("Callable[P, R]", self._resolved_callback) - @cached_property + @property def run_injected(self) -> Callable[P, R]: - return self._injection_store.inject(self.resolved_callback, processors=True) + """Return the command callback with dependencies injected. + + This property is cached, so the injected version is only created once. + """ + if self._injected_callback is None: + cb = self._injection_store.inject(self.resolved_callback, processors=True) + object.__setattr__(self, "_injected_callback", cb) + return cast("Callable[P, R]", self._injected_callback) class CommandsRegistry: @@ -86,7 +118,7 @@ def __init__( injection_store: Store | None = None, raise_synchronous_exceptions: bool = False, ) -> None: - self._commands: dict[str, _RegisteredCommand] = {} + self._commands: dict[str, RegisteredCommand] = {} self._injection_store = injection_store self._raise_synchronous_exceptions = raise_synchronous_exceptions @@ -133,7 +165,7 @@ def register_command( f"{self._commands[id].callback!r} (new callback: {callback!r})" ) - cmd = _RegisteredCommand(id, callback, title, self._injection_store) + cmd = RegisteredCommand(id, callback, title, self._injection_store) self._commands[id] = cmd def _dispose() -> None: @@ -142,7 +174,7 @@ def _dispose() -> None: self.registered.emit(id) return _dispose - def __iter__(self) -> Iterator[tuple[str, _RegisteredCommand]]: + def __iter__(self) -> Iterator[tuple[str, RegisteredCommand]]: yield from self._commands.items() def __len__(self) -> int: @@ -155,7 +187,7 @@ 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: + 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") From 5b2d176f360c000d59bdc8c5d1a21161856453c3 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 6 May 2024 15:29:24 -0400 Subject: [PATCH 6/8] fix docs --- src/app_model/registries/_commands_reg.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app_model/registries/_commands_reg.py b/src/app_model/registries/_commands_reg.py index 5a528982..9c420c1a 100644 --- a/src/app_model/registries/_commands_reg.py +++ b/src/app_model/registries/_commands_reg.py @@ -31,8 +31,8 @@ class RegisteredCommand(Generic[P, R]): 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` + command, so that type resolution and dependency injection is performed only + once. """ __slots__ = ( From 5fa200ebe228d4c16c5303777357116edf0cc48e Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 6 May 2024 15:35:40 -0400 Subject: [PATCH 7/8] test immutability --- src/app_model/registries/_commands_reg.py | 4 +--- tests/test_command_registry.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app_model/registries/_commands_reg.py b/src/app_model/registries/_commands_reg.py index 9c420c1a..d45448e9 100644 --- a/src/app_model/registries/_commands_reg.py +++ b/src/app_model/registries/_commands_reg.py @@ -63,9 +63,7 @@ def __init__( def __setattr__(self, name: str, value: Any) -> None: """Object is immutable after initialization.""" if getattr(self, "_initialized", False): - raise AttributeError( - f"Cannot set attribute {name!r} on {self.__class__.__name__} instance" - ) + raise AttributeError("RegisteredCommand object is immutable.") super().__setattr__(name, value) @property diff --git a/tests/test_command_registry.py b/tests/test_command_registry.py index 712d2d87..9e14f489 100644 --- a/tests/test_command_registry.py +++ b/tests/test_command_registry.py @@ -1,6 +1,6 @@ import pytest -from app_model.registries import CommandsRegistry +from app_model.registries import CommandsRegistry, RegisteredCommand def raise_exc() -> None: @@ -39,3 +39,12 @@ def test_commands_raises() -> None: with pytest.raises(RuntimeError, match="boom"): reg.execute_command("my.id") + + cmd = reg["my.id"] + assert isinstance(cmd, RegisteredCommand) + assert cmd.title == "My Title" + + with pytest.raises(AttributeError, match="immutable"): + cmd.title = "New Title" + + assert cmd.title == "My Title" From 06e523f3fd323bac662b6d86f09161a30cc0e18d Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 6 May 2024 15:36:54 -0400 Subject: [PATCH 8/8] same name --- tests/test_command_registry.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/tests/test_command_registry.py b/tests/test_command_registry.py index 9e14f489..7ef65451 100644 --- a/tests/test_command_registry.py +++ b/tests/test_command_registry.py @@ -9,18 +9,19 @@ def raise_exc() -> None: def test_commands_registry() -> None: reg = CommandsRegistry() - reg.register_command("my.id", lambda: 42, "My Title") + id1 = "my.id" + reg.register_command(id1, lambda: 42, "My Title") assert "(1 commands)" in repr(reg) - assert "my.id" in str(reg) - assert "my.id" in reg + assert id1 in str(reg) + assert id1 in reg with pytest.raises(KeyError, match="my.id2"): reg["my.id2"] with pytest.raises(ValueError, match="Command 'my.id' already registered"): - reg.register_command("my.id", lambda: 42, "My Title") + reg.register_command(id1, 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 + assert reg.execute_command(id1, execute_asynchronously=True).result() == 42 + assert reg.execute_command(id1, execute_asynchronously=False).result() == 42 reg.register_command("my.id2", raise_exc, "My Title 2") future_async = reg.execute_command("my.id2", execute_asynchronously=True) @@ -35,16 +36,18 @@ def test_commands_registry() -> None: def test_commands_raises() -> None: reg = CommandsRegistry(raise_synchronous_exceptions=True) - reg.register_command("my.id", raise_exc, "My Title") + id_ = "my.id" + title = "My Title" + reg.register_command(id_, raise_exc, title) with pytest.raises(RuntimeError, match="boom"): - reg.execute_command("my.id") + reg.execute_command(id_) - cmd = reg["my.id"] + cmd = reg[id_] assert isinstance(cmd, RegisteredCommand) - assert cmd.title == "My Title" + assert cmd.title == title with pytest.raises(AttributeError, match="immutable"): cmd.title = "New Title" - assert cmd.title == "My Title" + assert cmd.title == title