diff --git a/pyproject.toml b/pyproject.toml index 0ddb9f5a..4294e642 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,6 +109,7 @@ ignore = [ "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 + "D401", # First line should be in imperative mood "D413", # Missing blank line after last section "D416", # Section name should end with a colon ] diff --git a/src/app_model/_app.py b/src/app_model/_app.py index 87c6aba8..ec31172c 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -1,11 +1,24 @@ from __future__ import annotations import contextlib -from typing import TYPE_CHECKING, ClassVar, Dict, Iterable, List, Optional, Tuple, Type +import os +import sys +from typing import ( + TYPE_CHECKING, + ClassVar, + Dict, + Iterable, + List, + MutableMapping, + Optional, + Tuple, + Type, +) import in_n_out as ino from psygnal import Signal +from .expressions import Context, app_model_context from .registries import ( CommandsRegistry, KeyBindingsRegistry, @@ -41,6 +54,10 @@ class Application: (Optionally) override the class to use when creating the KeyBindingsRegistry injection_store_class : Type[ino.Store] (Optionally) override the class to use when creating the injection Store + context : Context | MutableMapping | None + (Optionally) provide a context to use for this application. If a + `MutableMapping` is provided, it will be used to create a `Context` instance. + If `None` (the default), a new `Context` instance will be created. Attributes ---------- @@ -52,6 +69,8 @@ class Application: The KeyBindings Registry for this application. injection_store : in_n_out.Store The Injection Store for this application. + context : Context + The Context for this application. """ destroyed = Signal(str) @@ -66,6 +85,7 @@ def __init__( menus_reg_class: Type[MenusRegistry] = MenusRegistry, keybindings_reg_class: Type[KeyBindingsRegistry] = KeyBindingsRegistry, injection_store_class: Type[ino.Store] = ino.Store, + context: Context | MutableMapping | None = None, ) -> None: self._name = name if name in Application._instances: @@ -75,6 +95,21 @@ def __init__( ) Application._instances[name] = self + if context is None: + context = Context() + elif isinstance(context, MutableMapping): + context = Context(context) + if not isinstance(context, Context): + raise TypeError( + f"context must be a Context or MutableMapping, got {type(context)}" + ) + self._context = context + self._context.update(app_model_context()) + + self._context["is_linux"] = sys.platform.startswith("linux") + self._context["is_mac"] = sys.platform == "darwin" + self._context["is_windows"] = os.name == "nt" + self._injection_store = injection_store_class.create(name) self._commands = commands_reg_class( self.injection_store, @@ -108,7 +143,7 @@ def menus(self) -> MenusRegistry: @property def keybindings(self) -> KeyBindingsRegistry: - """Return the [`KeyBindingsRegistry`][app_model.registries.KeyBindingsRegistry].""" # noqa + """Return the [`KeyBindingsRegistry`][app_model.registries.KeyBindingsRegistry].""" # noqa E501 return self._keybindings @property @@ -116,6 +151,11 @@ def injection_store(self) -> ino.Store: """Return the `in_n_out.Store` instance associated with this `Application`.""" return self._injection_store + @property + def context(self) -> Context: + """Return the [`Context`][app_model.expressions.Context] for this application.""" # noqa E501 + return self._context + @classmethod def get_or_create(cls, name: str) -> Application: """Get app named `name` or create and return a new one if it doesn't exist.""" diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index 0177d51b..73bd8550 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -170,13 +170,8 @@ def __init__( 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({}) + self.update_from_context(self._app.context) def update_from_context(self, ctx: Mapping[str, object]) -> None: """Update the enabled/visible state of this menu item from `ctx`.""" diff --git a/src/app_model/expressions/__init__.py b/src/app_model/expressions/__init__.py index 46439607..b6067d85 100644 --- a/src/app_model/expressions/__init__.py +++ b/src/app_model/expressions/__init__.py @@ -1,5 +1,5 @@ """Abstraction on expressions, and contexts in which to evaluate them.""" -from ._context import Context, create_context, get_context +from ._context import Context, app_model_context, create_context, get_context from ._context_keys import ContextKey, ContextKeyInfo, ContextNamespace from ._expressions import ( BinOp, @@ -15,13 +15,11 @@ ) __all__ = [ + "app_model_context", "BinOp", "BoolOp", "Compare", "Constant", - "IfExp", - "Name", - "UnaryOp", "Context", "ContextKey", "ContextKeyInfo", @@ -29,6 +27,9 @@ "create_context", "Expr", "get_context", + "IfExp", + "Name", "parse_expression", "safe_eval", + "UnaryOp", ] diff --git a/src/app_model/expressions/_context.py b/src/app_model/expressions/_context.py index b575fecd..168b1209 100644 --- a/src/app_model/expressions/_context.py +++ b/src/app_model/expressions/_context.py @@ -1,24 +1,24 @@ from __future__ import annotations +import os import sys from contextlib import contextmanager -from typing import ( - TYPE_CHECKING, - Any, - Callable, - ChainMap, - Dict, - Iterator, - MutableMapping, - Optional, - Type, -) +from typing import TYPE_CHECKING, Any, Callable, ChainMap, Iterator, MutableMapping from weakref import finalize from psygnal import Signal if TYPE_CHECKING: from types import FrameType + from typing import TypedDict + + class AppModelContextDict(TypedDict): + """Global context keys offered by app-model.""" + + is_linux: bool + is_mac: bool + is_windows: bool + _null = object() @@ -52,7 +52,7 @@ def __delitem__(self, k: str) -> None: if emit: self.changed.emit({k}) - def new_child(self, m: Optional[MutableMapping] = None) -> Context: + def new_child(self, m: MutableMapping | None = None) -> Context: """Create a new child context from this one.""" new = super().new_child(m=m) self.changed.connect(new.changed) @@ -67,8 +67,8 @@ def __hash__(self) -> int: # 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 +_OBJ_TO_CONTEXT: dict[int, Context] = {} +_ROOT_CONTEXT: Context | None = None def _pydantic_abort(frame: FrameType) -> bool: @@ -81,8 +81,8 @@ def create_context( obj: object, max_depth: int = 20, start: int = 2, - root: Optional[Context] = None, - root_class: Type[Context] = Context, + root: Context | None = None, + root_class: type[Context] = Context, frame_predicate: Callable[[FrameType], bool] = _pydantic_abort, ) -> Context: """Create context for any object. @@ -98,7 +98,7 @@ def create_context( 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 : 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 @@ -123,7 +123,7 @@ def create_context( parent = root if hasattr(sys, "_getframe"): # CPython implementation detail - frame: Optional[FrameType] = sys._getframe(start) + frame: FrameType | None = sys._getframe(start) i = -1 # traverse call stack looking for another object that has a context # to scope this new context off of. @@ -148,6 +148,15 @@ def create_context( return new_context -def get_context(obj: object) -> Optional[Context]: +def get_context(obj: object) -> Context | None: """Return context for any object, if found.""" return _OBJ_TO_CONTEXT.get(id(obj)) + + +def app_model_context() -> AppModelContextDict: + """A set of useful global context keys to use.""" + return { + "is_linux": sys.platform.startswith("linux"), + "is_mac": sys.platform == "darwin", + "is_windows": os.name == "nt", + } diff --git a/tests/conftest.py b/tests/conftest.py index 6bc67713..b165cdb1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import sys from pathlib import Path -from typing import List +from typing import TYPE_CHECKING from unittest.mock import Mock import pytest @@ -8,6 +10,9 @@ from app_model import Action, Application from app_model.types import KeyCode, KeyMod, SubmenuItem +if TYPE_CHECKING: + from typing import Iterator, NoReturn + try: from fonticon_fa6 import FA6S @@ -40,7 +45,7 @@ class Commands: RAISES = "raises.error" -def _raise_an_error(): +def _raise_an_error() -> NoReturn: raise ValueError("This is an error") @@ -101,7 +106,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: ] ) - actions: List[Action] = [ + actions: list[Action] = [ Action( id=Commands.OPEN, title="Open...", @@ -211,7 +216,7 @@ def build_app(name: str = "complete_test_app") -> FullApp: @pytest.fixture -def full_app(monkeypatch) -> Application: +def full_app(monkeypatch: pytest.MonkeyPatch) -> Iterator[Application]: """Premade application.""" try: app = build_app("complete_test_app") @@ -228,7 +233,7 @@ def full_app(monkeypatch) -> Application: @pytest.fixture -def simple_app(): +def simple_app() -> Iterator[Application]: app = Application("test") app.commands_changed = Mock() app.commands.registered.connect(app.commands_changed) diff --git a/tests/test_app.py b/tests/test_app.py index 5ffba718..61d79406 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,17 +1,19 @@ from __future__ import annotations +import os import sys from typing import TYPE_CHECKING import pytest from app_model import Application +from app_model.expressions import Context if TYPE_CHECKING: from conftest import FullApp -def test_app_create(): +def test_app_create() -> None: assert Application.get_app("my_app") is None app = Application("my_app") assert Application.get_app("my_app") is app @@ -27,7 +29,7 @@ def test_app_create(): Application.destroy("my_app") -def test_app(full_app: FullApp): +def test_app(full_app: FullApp) -> None: app = full_app app.commands.execute_command(app.Commands.OPEN) @@ -38,7 +40,7 @@ def test_app(full_app: FullApp): app.mocks.paste.assert_called_once() -def test_sorting(full_app: FullApp): +def test_sorting(full_app: FullApp) -> None: groups = list(full_app.menus.iter_menu_groups(full_app.Menus.EDIT)) assert len(groups) == 3 [g0, g1, g2] = groups @@ -49,7 +51,7 @@ def test_sorting(full_app: FullApp): assert [i.command.title for i in g2] == ["Copy", "Paste"] -def test_action_import_by_string(full_app: FullApp): +def test_action_import_by_string(full_app: FullApp) -> None: """the REDO command is declared as a string in the conftest.py file This tests that it can be lazily imported at callback runtime and executed @@ -77,7 +79,7 @@ def test_action_import_by_string(full_app: FullApp): full_app.commands.execute_command(full_app.Commands.NOT_CALLABLE) -def test_action_raises_exception(full_app: FullApp): +def test_action_raises_exception(full_app: FullApp) -> None: result = full_app.commands.execute_command(full_app.Commands.RAISES) with pytest.raises(ValueError): result.result() @@ -91,3 +93,25 @@ def test_action_raises_exception(full_app: FullApp): with pytest.raises(ValueError): full_app.commands.execute_command(full_app.Commands.RAISES) + + +def test_app_context() -> None: + app = Application("app1") + assert isinstance(app.context, Context) + Application.destroy("app1") + assert app.context["is_windows"] == (os.name == "nt") + assert "is_mac" in app.context + assert "is_linux" in app.context + + app = Application("app2", context={"a": 1}) + assert isinstance(app.context, Context) + assert app.context["a"] == 1 + Application.destroy("app2") + + app = Application("app3", context=Context({"a": 1})) + assert isinstance(app.context, Context) + assert app.context["a"] == 1 + Application.destroy("app3") + + with pytest.raises(TypeError, match="context must be a Context or MutableMapping"): + Application("app4", context=1) # type: ignore[arg-type]