diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 54e8e4df..9a366f3e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -20,7 +20,7 @@ repos: rev: v0.1.3 hooks: - id: ruff - args: ["--fix"] + args: ["--fix", "--unsafe-fixes"] - repo: https://github.com/abravalheri/validate-pyproject rev: v0.15 diff --git a/docs/_griffe_ext.py b/docs/_griffe_ext.py new file mode 100644 index 00000000..14cef624 --- /dev/null +++ b/docs/_griffe_ext.py @@ -0,0 +1,78 @@ +import ast +import contextlib +import inspect + +import fieldz +from fieldz._repr import display_as_type +from griffe import Extension, Object, ObjectNode, dynamic_import, get_logger +from griffe.dataclasses import Docstring +from griffe.docstrings.dataclasses import DocstringParameter, DocstringSectionParameters +from griffe.docstrings.utils import parse_annotation +from pydantic import BaseModel + +logger = get_logger(__name__) + + +class DynamicDocstrings(Extension): + def __init__(self, object_paths: list[str] | None = None) -> None: + self.object_paths = object_paths + + def on_instance(self, node: ast.AST | ObjectNode, obj: Object) -> None: + if isinstance(node, ObjectNode): + return # skip runtime objects, their docstrings are already right + + if self.object_paths and obj.path not in self.object_paths: + return # skip objects that were not selected + + # import object to get its evaluated docstring + try: + runtime_obj = dynamic_import(obj.path) + docstring = runtime_obj.__doc__ + except ImportError: + logger.debug(f"Could not get dynamic docstring for {obj.path}") + return + except AttributeError: + logger.debug(f"Object {obj.path} does not have a __doc__ attribute") + return + + with contextlib.suppress(TypeError): + fieldz.get_adapter(runtime_obj) + docstring = inspect.cleandoc(docstring) + self._inject_fields(docstring, obj, runtime_obj) + + def _inject_fields( + self, docstring: str, obj: Object, runtime_obj: type[BaseModel] + ) -> None: + # update the object instance with the evaluated docstring + if obj.docstring: + obj.docstring.value = docstring + else: + obj.docstring = Docstring(docstring, parent=obj) + + params = [ + DocstringParameter( + name=field.name, + annotation=( + parse_annotation( + display_as_type(field.type, modern_union=True), obj.docstring + ) + if field.type + else None + ), + description=field.description or "", + value=repr(field.default) + if field.default is not field.MISSING + else None, + ) + for field in fieldz.fields(runtime_obj) + if field.name in runtime_obj.__annotations__ + ] + param_section = DocstringSectionParameters(params) + # TODO: merge rather than overwrite + parsed = [ + x + for x in obj.docstring.parsed + if not isinstance(x, DocstringSectionParameters) + ] + parsed.append(param_section) + obj.docstring.parsed = parsed diff --git a/docs/_macros.py b/docs/_macros.py deleted file mode 100644 index 4c297a09..00000000 --- a/docs/_macros.py +++ /dev/null @@ -1,59 +0,0 @@ -import collections.abc -from importlib import import_module -from typing import TYPE_CHECKING, Any, TypeVar, Union - -from pydantic_compat 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_}]" diff --git a/docs/application.md b/docs/application.md deleted file mode 100644 index ce7fc391..00000000 --- a/docs/application.md +++ /dev/null @@ -1,5 +0,0 @@ -# Application - -::: app_model.Application - options: - show_signature: false diff --git a/docs/css/style.css b/docs/css/style.css new file mode 100644 index 00000000..49b8c022 --- /dev/null +++ b/docs/css/style.css @@ -0,0 +1,170 @@ +/* Increase logo size */ +.md-header__button.md-logo { + padding-bottom: 0.2rem; + padding-right: 0; +} +.md-header__button.md-logo img { + height: 1.5rem; +} + +/* Mark external links as such (also in nav) */ +a.external:hover::after, a.md-nav__link[href^="https:"]:hover::after { + /* https://primer.style/octicons/link-external-16 */ + background-image: url('data:image/svg+xml,'); + height: 0.8em; + width: 0.8em; + margin-left: 0.2em; + content: ' '; + display: inline-block; +} + +/* More space at the bottom of the page */ +.md-main__inner { + margin-bottom: 1.5rem; +} + +/* ------------------------------- */ + +/* Indentation. */ +div.doc-contents:not(.first) { + padding-left: 25px; + border-left: .05rem solid var(--md-typeset-table-color); +} + +/* Mark external links as such. */ +a.external::after, +a.autorefs-external::after { + /* https://primer.style/octicons/arrow-up-right-24 */ + mask-image: url('data:image/svg+xml,'); + -webkit-mask-image: url('data:image/svg+xml,'); + content: ' '; + + display: inline-block; + vertical-align: middle; + position: relative; + + height: 1em; + width: 1em; + background-color: var(--md-typeset-a-color); +} + +a.external:hover::after, +a.autorefs-external:hover::after { + background-color: var(--md-accent-fg-color); +} + +/* Avoid breaking parameters name, etc. in table cells. */ +td code { + word-break: normal !important; +} + +/* ------------------------------- */ + +/* Avoid breaking parameter names, etc. in table cells. */ +.doc-contents td code { + word-break: normal !important; +} + +/* No line break before first paragraph of descriptions. */ +.doc-md-description, +.doc-md-description>p:first-child { + display: inline; +} + +/* Max width for docstring sections tables. */ +.doc .md-typeset__table, +.doc .md-typeset__table table { + display: table !important; + width: 100%; +} + +.doc .md-typeset__table tr { + display: table-row; +} + +/* Defaults in Spacy table style. */ +.doc-param-default { + float: right; +} + +/* Symbols in Navigation and ToC. */ +:root, +[data-md-color-scheme="default"] { + --doc-symbol-attribute-fg-color: #953800; + --doc-symbol-function-fg-color: #8250df; + --doc-symbol-method-fg-color: #8250df; + --doc-symbol-class-fg-color: #0550ae; + --doc-symbol-module-fg-color: #5cad0f; + + --doc-symbol-attribute-bg-color: #9538001a; + --doc-symbol-function-bg-color: #8250df1a; + --doc-symbol-method-bg-color: #8250df1a; + --doc-symbol-class-bg-color: #0550ae1a; + --doc-symbol-module-bg-color: #5cad0f1a; +} + +[data-md-color-scheme="slate"] { + --doc-symbol-attribute-fg-color: #ffa657; + --doc-symbol-function-fg-color: #d2a8ff; + --doc-symbol-method-fg-color: #d2a8ff; + --doc-symbol-class-fg-color: #79c0ff; + --doc-symbol-module-fg-color: #baff79; + + --doc-symbol-attribute-bg-color: #ffa6571a; + --doc-symbol-function-bg-color: #d2a8ff1a; + --doc-symbol-method-bg-color: #d2a8ff1a; + --doc-symbol-class-bg-color: #79c0ff1a; + --doc-symbol-module-bg-color: #baff791a; +} + +code.doc-symbol { + border-radius: .1rem; + font-size: .85em; + padding: 0 .3em; + font-weight: bold; +} + +code.doc-symbol-attribute { + color: var(--doc-symbol-attribute-fg-color); + background-color: var(--doc-symbol-attribute-bg-color); +} + +code.doc-symbol-attribute::after { + content: "attr"; +} + +code.doc-symbol-function { + color: var(--doc-symbol-function-fg-color); + background-color: var(--doc-symbol-function-bg-color); +} + +code.doc-symbol-function::after { + content: "func"; +} + +code.doc-symbol-method { + color: var(--doc-symbol-method-fg-color); + background-color: var(--doc-symbol-method-bg-color); +} + +code.doc-symbol-method::after { + content: "meth"; +} + +code.doc-symbol-class { + color: var(--doc-symbol-class-fg-color); + background-color: var(--doc-symbol-class-bg-color); +} + +code.doc-symbol-class::after { + content: "class"; +} + +code.doc-symbol-module { + color: var(--doc-symbol-module-fg-color); + background-color: var(--doc-symbol-module-bg-color); +} + +code.doc-symbol-module::after { + content: "mod"; +} \ No newline at end of file diff --git a/docs/expressions.md b/docs/expressions.md deleted file mode 100644 index 0d3ac1df..00000000 --- a/docs/expressions.md +++ /dev/null @@ -1,14 +0,0 @@ -# Expressions - -::: app_model.expressions.Expr - options: - - members: - - parse - - eval - - __str__ - -::: app_model.expressions._expressions.parse_expression - options: - show_signature: yes - show_signature_annotations: yes diff --git a/docs/getting_started.md b/docs/getting_started.md new file mode 100644 index 00000000..862740b4 --- /dev/null +++ b/docs/getting_started.md @@ -0,0 +1,249 @@ +# Getting Started + +## Creating an Application + +Typical usage will begin by creating a [`Application`][app_model.Application] +object. + +```python +from app_model import Application + +my_app = Application('my-app') +``` + +## Registering Actions + +Most applications will have some number of actions that can be invoked by the +user. Actions are typically callable objects that perform some operation, such +as "open a file", "save a file", "copy", "paste", etc. +These actions will usually be exposed in the application's menus and +toolbars, and will usually have associated keybindings. Sometimes actions +hold state, such as "toggle word wrap" or "toggle line numbers". + +`app-model` provides a high level [`Action`][app_model.Action] object that +comprises a pointer to a callable object, along with placement in menus, keybindings, +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!') + +def close_window(): + print('close window!') + +ACTIONS: list[Action] = [ + Action( + id='open', + title="Open", + icon="fa6-solid:folder-open", + callback=open_file, + menus=['File'], + keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyO)], + ), + Action( + id='close', + title="Close", + icon="fa-solid:window-close", + callback=close_window, + menus=['File'], + keybindings=[KeyBindingRule(primary=KeyMod.CtrlCmd | KeyCode.KeyW)], + ), + # ... +] +``` + +Actions are registered with the application using the +[`Application.register_action()`][app_model.Application.register_action] method. + +```python +for action in ACTIONS: + my_app.register_action(action) +``` + +## Registries + +The application maintains three internal registries. + +1. `Application.commands` is an instance of + [`CommandsRegistry`][app_model.registries.CommandsRegistry]. It maintains + all of the commands (the actual callable object) that have been registered with + the application. +2. `Application.menus` is an instance of + [`MenusRegistry`][app_model.registries.MenusRegistry]. It maintains all of + the menus and submenu items that have been registered with the application. +3. `Application.keybindings` is an instance of + [`KeyBindingsRegistry`][app_model.registries.KeyBindingsRegistry]. It maintains + an association between a [KeyBinding][app_model.types.KeyBinding] and a command + id in the `CommandsRegistry`. + +!!! 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] + +### Registry events + +Each of these registries has a signal that is emitted when a new item is added. + +- `CommandsRegistry.registered` is emitted with the new command id (`str`) whenever + [`CommandsRegistry.register_command`][app_model.registries.CommandsRegistry.register_command] + is called +- `MenusRegistry.menus_changed` is emitted with the new menu ids (`set[str]`) whenever + [`MenusRegistry.append_menu_items`][app_model.registries.MenusRegistry.append_menu_items] + or if the menu items have been disposed. +- `KeyBindingsRegistry.registered` is emitted (no arguments) whenever + [`KeyBindingsRegistry.register_keybinding_rule`][app_model.registries.KeyBindingsRegistry.register_keybinding_rule] is called. + +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!') + +my_app.commands.register_command('new-id', lambda: None, title='No-op') +# Command 'new-id' registered! +``` + +## Executing Commands + +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') +# prints "open file!" from the `open_file` function registered above. +``` + +### Command Arguments and Dependency Injection + +The `execute_command` function does accept `*args` and `**kwargs` that will +be passed to the command. However, very often in a GUI application +you may wish to infer some of the arguments from the current state of the +application. For example, if you have menu item linked to a "close window", +you likely want to close the current window. For this, `app-model` uses +a dependency injection pattern, provided by the +[`in-n-out`](https://github.com/pyapp-kit/in-n-out) library. + +The application has a [`injection_store`][app_model.Application.injection_store] +attribute that is an instance of an `in_n_out.Store`. A `Store` is a collection +of: + +- **providers**: Functions that can be called to return an instance of a given + type. These may be used to provide arguments to commands, based on the type + annotations in the command function definition. +- **processors**: Functions that accept an instance of a given type and do + something with it. These are used to process the return value of the command + function at execution time, based on command definition return type annotations. + +Here's an example. Let's say an application has a `User` object with a `name()` +method: + +```python +class User: + def name(self): + return 'John Doe' +``` + +Assume the application has some way of retrieving the current user: + +```python +def get_current_user() -> User: + # ... get the current user from somewhere + return User() +``` + +We register this provider function with the application's injection store: + +```python +my_app.injection_store.register_provider(get_current_user) +``` + +Now commands may be defined that accept a `User` argument, and used +for callbacks in actions registered with the application. + +```python +def print_user_name(user: User) -> None: + print(f"Hi {user.name()}!") + +action = Action( + id='greet', + title="Greet Current User", + callback=print_user_name, +) + +my_app.register_action(action) +my_app.commands.execute_command('greet') +# prints "Hi John Doe!" +``` + +## Connecting a GUI framework + +Of course, most of this is useless without some way to connect the application +to a GUI framework. The [`app_model.backends`][app_model.backends] module +provides functions that map the `app-model` model onto various GUI framework models. + +!!! note "erm... someday 😂" + + Well, really it's just Qt for now, but the abstraction is valuable for the + ability to swap backends. And we hope to add more backends if the demand is + there. + +### Qt + +Currently, we don't have a generic abstraction for the application window, so +users are encouraged to directly use the classes in the `app_model.backends.qt` +module. One of the main classes is the [`QModelMainWindow`][app_model.backends.qt.QModelMainWindow] object: a subclass of `QMainWindow` that knows how to map +an `Application` object onto the Qt model. + +```python +from app_model.backends.qt import QModelMainWindow +from qtpy.QtWidgets import QApplication + +app = QApplication([]) + +# create the main window with our app_model.Application +main = QModelMainWindow(my_app) + +# pick menus for main menu bar, +# using menu ids from the application's MenusRegistry +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.show() + +app.exec_() +``` + +You should now have a QMainWindow with a menu bar and toolbar populated with +the actions you registered with the application with icons, keybindings, +and callbacks all connected. + +![QMainWindow with menu bar and toolbar](images/qmainwindow.jpeg) + +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. + +See all objects in the [Qt backend API docs][app_model.backends.qt]. + +!!! Tip + + Application registries are backed by + [psygnal](https://github.com/pyapp-kit/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. diff --git a/docs/images/qmainwindow.jpeg b/docs/images/qmainwindow.jpeg new file mode 100644 index 00000000..4fdb2c88 Binary files /dev/null and b/docs/images/qmainwindow.jpeg differ diff --git a/docs/index.md b/docs/index.md index 3ecbc551..1aa54108 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,23 +6,25 @@ 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 +## Installation -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] +Install from pip -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]. +```bash +pip install app-model +``` + +Or from conda-forge -!!! 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: +```bash +conda install -c conda-forge app-model +``` - - [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] +## Usage + +See the [Getting Started](getting_started.md) guide for a quick introduction to +`app-model`. See the [API Reference](reference/index.md) for a complete +reference of the types and functions provided by `app-model`. ## Motivation @@ -30,17 +32,24 @@ 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. + 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. + 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 framework) responding to those events to update the actual + presentation. -1. **It decouples the structure of the application from the underlying backend** +1. **It decouples the structure of the application from the GUI framework** - This makes it easier to change the backend without having to change the - application. (Obviously, as an application grows with a particular backend, + This makes it easier to change the GUI framework without having to change the + application. (Obviously, as an application grows with a particular framework, it does become harder to extract, but having a loosely coupled model is a step in the right direction) @@ -50,34 +59,20 @@ Why bother with a declarative application model? 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. +## GUI Frameworks -### 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) -``` +`app-model` is framework-agnostic, and can be used with any GUI toolkit, but +[Qt](https://www.qt.io) is currently the primary target, and a +[Qt adapter][app_model.backends.qt] comes with this library. -!!! 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. +See some details in the [qt section](getting_started.md#qt) of the getting +started guide. -### Example Application +## 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. +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. diff --git a/docs/keybindings.md b/docs/keybindings.md deleted file mode 100644 index 40515d12..00000000 --- a/docs/keybindings.md +++ /dev/null @@ -1,8 +0,0 @@ -# KeyCodes and KeyBindings - -::: app_model.types.KeyCode - options: - show_signature_annotations: yes -::: app_model.types.KeyMod -::: app_model.types.KeyCombo -::: app_model.types.KeyChord diff --git a/docs/my_hooks.py b/docs/my_hooks.py new file mode 100644 index 00000000..bb8d5c65 --- /dev/null +++ b/docs/my_hooks.py @@ -0,0 +1,7 @@ +from typing import Any + + +def on_page_markdown(md: str, **kwargs: Any) -> str: + T = "::: app_model.types" + T2 = T + "\n\toptions:\n\t\tdocstring_section_style: table" + return md.replace(T, T2) diff --git a/docs/registries.md b/docs/registries.md deleted file mode 100644 index c157d7e6..00000000 --- a/docs/registries.md +++ /dev/null @@ -1,5 +0,0 @@ -# Registries - -::: app_model.registries.CommandsRegistry -::: app_model.registries.KeyBindingsRegistry -::: app_model.registries.MenusRegistry diff --git a/docs/types.md b/docs/types.md deleted file mode 100644 index 9e995fae..00000000 --- a/docs/types.md +++ /dev/null @@ -1,24 +0,0 @@ -# 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') }} diff --git a/mkdocs.yml b/mkdocs.yml index be14a02a..0ce514b0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,36 +2,54 @@ 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. +# strict: true repo_name: pyapp-kit/app-model repo_url: https://github.com/pyapp-kit/app-model -copyright: Copyright © 2021 - 2022 Talley Lambert +copyright: Copyright © 2021 - 2023 Talley Lambert watch: - src +nav: + - index.md + - getting_started.md + # defer to gen-files + literate-nav + - API reference: reference/ + plugins: - search + - gen-files: + scripts: + - scripts/gen_ref_nav.py + - literate-nav: + nav_file: SUMMARY.txt - autorefs - minify: minify_html: true - - macros: - module_name: docs/_macros - mkdocstrings: handlers: python: import: - https://docs.python.org/3/objects.inv options: + extensions: + - docs/_griffe_ext.py:DynamicDocstrings docstring_style: numpy - show_bases: false - merge_init_into_class: yes - show_source: no - show_root_full_path: no - show_root_heading: yes + docstring_options: + ignore_init_summary: true docstring_section_style: list - + filters: ["!^_"] + heading_level: 1 + inherited_members: true + merge_init_into_class: true + separate_signature: true + show_root_heading: true + show_root_full_path: false + show_signature_annotations: true + show_bases: true + show_source: true markdown_extensions: - tables @@ -40,6 +58,11 @@ markdown_extensions: - pymdownx.superfences - pymdownx.details - admonition + - toc: + permalink: "#" + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg theme: name: material @@ -48,5 +71,13 @@ theme: logo: material/application-cog-outline features: - navigation.instant + - navigation.indexes - search.highlight - search.suggest + - navigation.expand + +extra_css: + - css/style.css + +hooks: + - docs/my_hooks.py diff --git a/pyproject.toml b/pyproject.toml index 691fb554..0ddb9f5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,14 +53,17 @@ dev = [ "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", + "griffe==0.36.9", + "mkdocs-material==9.4.1", + "mkdocs-minify-plugin==0.7.1", + "mkdocs==1.5.3", + "mkdocstrings-python==1.7.3", + "mkdocstrings==0.23.0", + "mkdocs-macros-plugin==1.0.5", "typing_extensions>=4.0", + "mkdocs-gen-files", + "mkdocs-literate-nav", + "fieldz", ] [project.urls] @@ -138,7 +141,7 @@ pretty = true plugins = ["pydantic.mypy"] [[tool.mypy.overrides]] -module = ["tests.*"] +module = ["tests.*", "docs.*"] disallow_untyped_defs = false [[tool.mypy.overrides]] diff --git a/scripts/gen_ref_nav.py b/scripts/gen_ref_nav.py new file mode 100644 index 00000000..c95bd325 --- /dev/null +++ b/scripts/gen_ref_nav.py @@ -0,0 +1,37 @@ +"""Generate the code reference pages and navigation.""" + +from pathlib import Path + +import mkdocs_gen_files + +SRC = Path("src") +PKG = SRC / "app_model" + +nav = mkdocs_gen_files.Nav() +mod_symbol = '' + +for path in sorted(SRC.rglob("*.py")): + module_path = path.relative_to("src").with_suffix("") + doc_path = path.relative_to(PKG).with_suffix(".md") + full_doc_path = Path("reference", doc_path) + + parts = tuple(module_path.parts) + + if parts[-1] == "__init__": + parts = parts[:-1] + doc_path = doc_path.with_name("index.md") + full_doc_path = full_doc_path.with_name("index.md") + if parts[-1].startswith("_"): + continue + + nav_parts = [f"{mod_symbol} {part}" for part in parts] + nav[tuple(nav_parts)] = doc_path.as_posix() + + with mkdocs_gen_files.open(full_doc_path, "w") as fd: + ident = ".".join(parts) + fd.write(f"::: {ident}") + + mkdocs_gen_files.set_edit_path(full_doc_path, ".." / path) + +with mkdocs_gen_files.open("reference/SUMMARY.txt", "w") as nav_file: + nav_file.writelines(nav.build_literate_nav()) diff --git a/src/app_model/_app.py b/src/app_model/_app.py index 1be2bb7e..87c6aba8 100644 --- a/src/app_model/_app.py +++ b/src/app_model/_app.py @@ -44,13 +44,13 @@ class Application: Attributes ---------- - - commands : CommandsRegistry + commands : CommandsRegistry The Commands Registry for this application. - - menus : MenusRegistry + menus : MenusRegistry The Menus Registry for this application. - - keybindings : KeyBindingsRegistry + keybindings : KeyBindingsRegistry The KeyBindings Registry for this application. - - injection_store : in_n_out.Store + injection_store : in_n_out.Store The Injection Store for this application. """ diff --git a/src/app_model/backends/qt/_qaction.py b/src/app_model/backends/qt/_qaction.py index b6f9ee36..0177d51b 100644 --- a/src/app_model/backends/qt/_qaction.py +++ b/src/app_model/backends/qt/_qaction.py @@ -67,12 +67,14 @@ class QCommandRuleAction(QCommandAction): Parameters ---------- - command_id : str - Command ID. + command_rule : CommandRule + `CommandRule` instance to create an action for. app : Union[str, Application] Application instance or name of application instance. parent : Optional[QWidget] Optional parent widget, by default None + use_short_title : bool + If True, use the `short_title` of the command rule, if it exists. """ def __init__( @@ -123,7 +125,7 @@ def _refresh(self) -> None: class QMenuItemAction(QCommandRuleAction): """QAction for a MenuItem. - Mostly the same as a CommandRuleAction, but aware of the `menu_item.when` clause + Mostly the same as a `CommandRuleAction`, but aware of the `menu_item.when` clause to toggle visibility. """ diff --git a/src/app_model/backends/qt/_qkeymap.py b/src/app_model/backends/qt/_qkeymap.py index 6a3f8d04..88956e7e 100644 --- a/src/app_model/backends/qt/_qkeymap.py +++ b/src/app_model/backends/qt/_qkeymap.py @@ -8,14 +8,14 @@ from qtpy.QtCore import QCoreApplication, Qt from qtpy.QtGui import QKeySequence -from app_model.types._constants import OperatingSystem -from app_model.types._keys import ( +from app_model.types import ( KeyBinding, KeyCode, KeyCombo, KeyMod, SimpleKeyBinding, ) +from app_model.types._constants import OperatingSystem if TYPE_CHECKING: from qtpy.QtCore import QKeyCombination diff --git a/src/app_model/expressions/_expressions.py b/src/app_model/expressions/_expressions.py index 102b0a4c..cc6b102e 100644 --- a/src/app_model/expressions/_expressions.py +++ b/src/app_model/expressions/_expressions.py @@ -1,4 +1,4 @@ -"""Provides the :class:`Expr` and its subclasses.""" +"""Provides `Expr` and its subclasses.""" from __future__ import annotations import ast @@ -39,12 +39,12 @@ def parse_expression(expr: Union[str, Expr]) -> Expr: - """Parse string expression into an :class:`Expr` instance. + """Parse string expression into an [`Expr`][app_model.expressions.Expr] instance. Parameters ---------- expr : Union[str, Expr] - Expression to parse. (If already an :class:`Expr`, it is returned) + Expression to parse. (If already an `Expr`, it is returned) Returns ------- diff --git a/src/app_model/types/__init__.py b/src/app_model/types/__init__.py index 3074378e..d3c9d9a7 100644 --- a/src/app_model/types/__init__.py +++ b/src/app_model/types/__init__.py @@ -14,6 +14,7 @@ ) from ._menu_rule import ( MenuItem, + MenuItemBase, MenuOrSubmenu, MenuRule, MenuRuleDict, @@ -35,6 +36,7 @@ "KeyCombo", "KeyMod", "MenuItem", + "MenuItemBase", "MenuOrSubmenu", "MenuRule", "MenuRuleDict", diff --git a/src/app_model/types/_action.py b/src/app_model/types/_action.py index a8b49712..52748d90 100644 --- a/src/app_model/types/_action.py +++ b/src/app_model/types/_action.py @@ -23,7 +23,7 @@ class Action(CommandRule, Generic[P, R]): - """Callable object along with specific context, menu, keybindings logic. + """An Action is a callable object with menu placement, keybindings, and metadata. 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. @@ -41,7 +41,9 @@ class Action(CommandRule, Generic[P, R]): ) menus: Optional[List[MenuRule]] = Field( None, - description="(Optional) Menus to which this action should be added.", + description="(Optional) Menus to which this action should be added. Note that " + "menu items in the sequence may be supplied as a plain string, which will " + "be converted to a `MenuRule` with the string as the `id` field.", ) keybindings: Optional[List[KeyBindingRule]] = Field( None, diff --git a/src/app_model/types/_keys/_key_codes.py b/src/app_model/types/_keys/_key_codes.py index 20f43f49..1b5cbabb 100644 --- a/src/app_model/types/_keys/_key_codes.py +++ b/src/app_model/types/_keys/_key_codes.py @@ -196,8 +196,16 @@ class ScanCode(IntEnum): https://en.wikipedia.org/wiki/Scancode - These are the scan codea required to conform to the W3C specification for + These are the scan codes required to conform to the W3C specification for KeyboardEvent.code + + A scan code is a hardware-specific code that is generated by the keyboard when a key + is pressed or released. It represents the physical location of a key on the keyboard + and is unique to each key. A key code, on the other hand, is a higher-level + representation of a keypress or key release event. They are associated with + characters, functions, or actions rather than hardware locations. + As an example, the left and right control keys have the same key code (KeyCode.Ctrl) + but different scan codes (LeftControl and RightControl). https://w3c.github.io/uievents-code/ @@ -694,6 +702,13 @@ class KeyChord(int): It could be two [`KeyCombo`][app_model.types.KeyCombo] [`KeyCode`][app_model.types.KeyCode], or [int][]. + + Parameters + ---------- + first_part : KeyCombo | int + The first part of the chord. + second_part : KeyCombo | int + The second part of the chord. """ def __new__(cls: Type["KeyChord"], first_part: int, second_part: int) -> "KeyChord": diff --git a/src/app_model/types/_keys/_keybindings.py b/src/app_model/types/_keys/_keybindings.py index b5b09bed..b8c8cc2e 100644 --- a/src/app_model/types/_keys/_keybindings.py +++ b/src/app_model/types/_keys/_keybindings.py @@ -22,11 +22,13 @@ 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 + ctrl: bool = Field(False, description='Whether the "Ctrl" modifier is active.') + shift: bool = Field(False, description='Whether the "Shift" modifier is active.') + alt: bool = Field(False, description='Whether the "Alt" modifier is active.') + meta: bool = Field(False, description='Whether the "Meta" modifier is active.') + key: Optional[KeyCode] = Field( + None, description="The key that is pressed (e.g. `KeyCode.A`)" + ) # def hash_code(self) -> str: # used by vscode for caching during keybinding resolution @@ -143,13 +145,18 @@ class KeyBinding: 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'. + + Parameters + ---------- + parts : List[SimpleKeyBinding] + The parts of the keybinding. There must be at least one part. """ + parts: List[SimpleKeyBinding] = Field(..., **MIN1) # type: ignore + 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) diff --git a/src/app_model/types/_menu_rule.py b/src/app_model/types/_menu_rule.py index 37b4c85f..500b8df6 100644 --- a/src/app_model/types/_menu_rule.py +++ b/src/app_model/types/_menu_rule.py @@ -17,7 +17,7 @@ from ._icon import Icon -class _MenuItemBase(_BaseModel): +class MenuItemBase(_BaseModel): """Data representing where and when a menu item should be shown.""" when: Optional[expressions.Expr] = Field( @@ -43,9 +43,9 @@ def __get_validators__(cls) -> Generator[Callable[..., Any], None, None]: yield cls._validate @classmethod - def _validate(cls: Type["_MenuItemBase"], v: Any) -> "_MenuItemBase": + def _validate(cls: Type["MenuItemBase"], v: Any) -> "MenuItemBase": """Validate icon.""" - if isinstance(v, _MenuItemBase): + if isinstance(v, MenuItemBase): return v if isinstance(v, dict): if "command" in v: @@ -57,7 +57,7 @@ def _validate(cls: Type["_MenuItemBase"], v: Any) -> "_MenuItemBase": raise ValueError(f"Invalid menu item: {v!r}", cls) # pragma: no cover -class MenuRule(_MenuItemBase): +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`. @@ -79,7 +79,7 @@ def _validate_model(cls, v: Any) -> Any: return {"id": v} if isinstance(v, str) else v -class MenuItem(_MenuItemBase): +class MenuItem(MenuItemBase): """Combination of a Command and conditions for menu presentation. This object is mostly constructed by `register_action` right before menu item @@ -103,7 +103,7 @@ def _simplify_command_rule(cls, v: Any) -> CommandRule: raise TypeError("command must be a CommandRule") # pragma: no cover -class SubmenuItem(_MenuItemBase): +class SubmenuItem(MenuItemBase): """Point to another Menu that will be displayed as a submenu.""" submenu: str = Field(..., description="Menu to insert as a submenu.")