Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
]
Expand Down
44 changes: 42 additions & 2 deletions src/app_model/_app.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
----------
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -108,14 +143,19 @@ 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
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."""
Expand Down
7 changes: 1 addition & 6 deletions src/app_model/backends/qt/_qaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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`."""
Expand Down
9 changes: 5 additions & 4 deletions src/app_model/expressions/__init__.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -15,20 +15,21 @@
)

__all__ = [
"app_model_context",
"BinOp",
"BoolOp",
"Compare",
"Constant",
"IfExp",
"Name",
"UnaryOp",
"Context",
"ContextKey",
"ContextKeyInfo",
"ContextNamespace",
"create_context",
"Expr",
"get_context",
"IfExp",
"Name",
"parse_expression",
"safe_eval",
"UnaryOp",
]
47 changes: 28 additions & 19 deletions src/app_model/expressions/_context.py
Original file line number Diff line number Diff line change
@@ -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()

Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand All @@ -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.
Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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",
}
15 changes: 10 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
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

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

Expand Down Expand Up @@ -40,7 +45,7 @@ class Commands:
RAISES = "raises.error"


def _raise_an_error():
def _raise_an_error() -> NoReturn:
raise ValueError("This is an error")


Expand Down Expand Up @@ -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...",
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down
34 changes: 29 additions & 5 deletions tests/test_app.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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]