mateu-ui 3.0.0a418__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. mateu_core/__init__.py +24 -0
  2. mateu_core/_version.py +8 -0
  3. mateu_core/action_guard.py +276 -0
  4. mateu_core/action_registry.py +319 -0
  5. mateu_core/capabilities.py +49 -0
  6. mateu_core/component_registry.py +123 -0
  7. mateu_core/dev_specs.py +223 -0
  8. mateu_core/documents.py +203 -0
  9. mateu_core/environments.py +176 -0
  10. mateu_core/error_boundary.py +77 -0
  11. mateu_core/errors.py +40 -0
  12. mateu_core/export.py +56 -0
  13. mateu_core/field_type_registry.py +223 -0
  14. mateu_core/identity.py +92 -0
  15. mateu_core/importwizard.py +389 -0
  16. mateu_core/inference_switches.py +36 -0
  17. mateu_core/islands.py +93 -0
  18. mateu_core/labels_aside_inference.py +73 -0
  19. mateu_core/layout_delta.py +196 -0
  20. mateu_core/layout_inference.py +77 -0
  21. mateu_core/mapper/__init__.py +368 -0
  22. mateu_core/mapper/_base.py +16 -0
  23. mateu_core/mapper/_common.py +447 -0
  24. mateu_core/mapper/app.py +603 -0
  25. mateu_core/mapper/archetypes.py +233 -0
  26. mateu_core/mapper/components.py +1295 -0
  27. mateu_core/mapper/crud.py +759 -0
  28. mateu_core/mapper/decorations.py +154 -0
  29. mateu_core/mapper/fields.py +720 -0
  30. mateu_core/mapper/layout.py +624 -0
  31. mateu_core/mapper/view.py +291 -0
  32. mateu_core/mapper/wizard.py +262 -0
  33. mateu_core/mcp.py +318 -0
  34. mateu_core/menu_route_conformance.py +55 -0
  35. mateu_core/mobile_manifest.py +94 -0
  36. mateu_core/naming.py +58 -0
  37. mateu_core/page_inference.py +63 -0
  38. mateu_core/page_type_inference.py +72 -0
  39. mateu_core/partial_registry.py +80 -0
  40. mateu_core/py.typed +0 -0
  41. mateu_core/reflection.py +103 -0
  42. mateu_core/registry.py +233 -0
  43. mateu_core/request_context.py +81 -0
  44. mateu_core/rest_source_registry.py +280 -0
  45. mateu_core/route_registry.py +545 -0
  46. mateu_core/sample_sources.py +27 -0
  47. mateu_core/sync_handler/__init__.py +273 -0
  48. mateu_core/sync_handler/_base.py +16 -0
  49. mateu_core/sync_handler/_common.py +161 -0
  50. mateu_core/sync_handler/actions.py +197 -0
  51. mateu_core/sync_handler/app.py +187 -0
  52. mateu_core/sync_handler/contract.py +76 -0
  53. mateu_core/sync_handler/crud.py +561 -0
  54. mateu_core/sync_handler/dispatch.py +466 -0
  55. mateu_core/sync_handler/grid_field.py +307 -0
  56. mateu_core/sync_handler/helpers.py +268 -0
  57. mateu_core/sync_handler/listing.py +275 -0
  58. mateu_core/sync_handler/proxy.py +164 -0
  59. mateu_core/sync_handler/render.py +69 -0
  60. mateu_core/sync_handler/search.py +578 -0
  61. mateu_core/sync_handler/wizard.py +161 -0
  62. mateu_core/translations.py +247 -0
  63. mateu_core/url_template.py +82 -0
  64. mateu_core/validation.py +215 -0
  65. mateu_core/yaml_access.py +210 -0
  66. mateu_core/yaml_preview.py +458 -0
  67. mateu_core/yaml_spec_loader.py +251 -0
  68. mateu_dtos/__init__.py +219 -0
  69. mateu_dtos/app.py +160 -0
  70. mateu_dtos/base.py +20 -0
  71. mateu_dtos/basic.py +203 -0
  72. mateu_dtos/components.py +284 -0
  73. mateu_dtos/dashboard.py +212 -0
  74. mateu_dtos/envelope.py +145 -0
  75. mateu_dtos/fields.py +217 -0
  76. mateu_dtos/front_office.py +403 -0
  77. mateu_dtos/layout.py +155 -0
  78. mateu_dtos/overlays.py +89 -0
  79. mateu_dtos/py.typed +0 -0
  80. mateu_dtos/records.py +261 -0
  81. mateu_dtos/ux.py +328 -0
  82. mateu_fastapi/__init__.py +401 -0
  83. mateu_fastapi/py.typed +0 -0
  84. mateu_ui-3.0.0a418.dist-info/METADATA +278 -0
  85. mateu_ui-3.0.0a418.dist-info/RECORD +113 -0
  86. mateu_ui-3.0.0a418.dist-info/WHEEL +4 -0
  87. mateu_uidl/__init__.py +372 -0
  88. mateu_uidl/action_catalog.py +51 -0
  89. mateu_uidl/adapters.py +64 -0
  90. mateu_uidl/archetype_pages.py +600 -0
  91. mateu_uidl/archetypes.py +282 -0
  92. mateu_uidl/business_components.py +46 -0
  93. mateu_uidl/class_decorators.py +695 -0
  94. mateu_uidl/components.py +2033 -0
  95. mateu_uidl/constraints.py +100 -0
  96. mateu_uidl/documents.py +81 -0
  97. mateu_uidl/environments.py +35 -0
  98. mateu_uidl/export.py +130 -0
  99. mateu_uidl/field_types.py +116 -0
  100. mateu_uidl/flow.py +107 -0
  101. mateu_uidl/i18n.py +29 -0
  102. mateu_uidl/listing.py +337 -0
  103. mateu_uidl/live_header_values.py +62 -0
  104. mateu_uidl/markers.py +327 -0
  105. mateu_uidl/messages.py +81 -0
  106. mateu_uidl/method_decorators.py +231 -0
  107. mateu_uidl/patterns.py +216 -0
  108. mateu_uidl/placement.py +107 -0
  109. mateu_uidl/py.typed +0 -0
  110. mateu_uidl/rest_sources.py +207 -0
  111. mateu_uidl/security.py +135 -0
  112. mateu_uidl/suppliers.py +249 -0
  113. mateu_uidl/views.py +131 -0
mateu_core/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """The Mateu engine: route registry, reflection mapper, and the sync use-case."""
2
+
3
+ from ._version import __version__
4
+ from .action_guard import MateuForbiddenException
5
+ from .importwizard import ColumnMapping, ImportWizard, RowIssue, detect_separator, parse_csv
6
+ from .mapper import ReflectionMapper
7
+ from .registry import MateuRegistry, normalize, type_name
8
+ from .sync_handler import RunActionRq, SyncHandler
9
+
10
+ __all__ = [
11
+ "ColumnMapping",
12
+ "ImportWizard",
13
+ "MateuForbiddenException",
14
+ "MateuRegistry",
15
+ "ReflectionMapper",
16
+ "RowIssue",
17
+ "RunActionRq",
18
+ "SyncHandler",
19
+ "detect_separator",
20
+ "normalize",
21
+ "parse_csv",
22
+ "type_name",
23
+ "__version__",
24
+ ]
mateu_core/_version.py ADDED
@@ -0,0 +1,8 @@
1
+ """The single source of the package version.
2
+
3
+ Development builds carry ``3.0.0.dev0``. The release workflow rewrites this line from the release
4
+ tag (``v3.0-alpha.406`` → ``3.0.0a406``, see ``scripts/set_version.py``) before building, so the
5
+ PyPI package moves in lockstep with the Maven artifacts.
6
+ """
7
+
8
+ __version__ = "3.0.0a418"
@@ -0,0 +1,276 @@
1
+ """The security gate of the action pipeline (mirrors Java's action authorization).
2
+
3
+ An ``actionId`` arrives from the wire, so it must only ever reach a method DECLARED as an action.
4
+ :func:`resolve_action` accepts the member whose camelCase name is the action id only when:
5
+
6
+ - its name does not start with ``_`` (no private, name-mangled or dunder members);
7
+ - it is a plain function defined in a class body (``inspect.isfunction``) — never a nested
8
+ class, a ``staticmethod``/``classmethod`` object, a callable class attribute or a builtin;
9
+ - it is marked as an action — ``@action``, ``@button`` or ``@fab`` — or its id is ADVERTISED by
10
+ the view: an ``OnRowSelected`` value, a ``@subscribe_to`` action, the view's refresh action,
11
+ any ``*action_id`` of its component tree (fluent archetype tree, ``@auto_page``-inferred tree
12
+ or the YAML layout bound to it), of its ``RuleSupplier`` rules or of its
13
+ ``AppActionsSupplier`` header actions;
14
+ - framework base classes (``mateu_uidl``/``mateu_core``/…, ``object``, ``typing``) only
15
+ contribute MARKED methods — the advertised path never reaches them.
16
+
17
+ Bulk row actions (``action-on-row-*``) only reach ``@list_toolbar_button`` methods
18
+ (:func:`resolve_row_action`). Once resolved, :func:`ensure_may_invoke` enforces the access
19
+ decorators at invocation (``eyes_only``, ``disabled_unless``, ``audience``); a denied invocation
20
+ raises :class:`MateuForbiddenException` (HTTP 403 at the FastAPI edge) and the method never runs.
21
+ A class-level ``@eyes_only`` refuses the whole type (:func:`ensure_class_access`). The field marker
22
+ ``EyesOnly`` is enforced on render and — see :func:`may_write` — on binding.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import dataclasses
28
+ import inspect
29
+ import logging
30
+ from typing import Any, Callable
31
+
32
+ from mateu_uidl import (
33
+ AppActionsSupplier,
34
+ EyesOnly,
35
+ OnRowSelected,
36
+ ReadOnlyUnless,
37
+ RuleSupplier,
38
+ )
39
+ from mateu_uidl import components as fluent
40
+
41
+ from .naming import camel_case
42
+ from .reflection import view_fields
43
+
44
+ log = logging.getLogger("mateu.security")
45
+
46
+ #: Decorator markers that make a method invocable as a view action.
47
+ ACTION_MARKERS = ("__mateu_action__", "__mateu_button__", "__mateu_fab__")
48
+ ROW_ACTION_MARKER = "__mateu_list_toolbar_button__"
49
+
50
+ _FRAMEWORK_PACKAGES = {"mateu_uidl", "mateu_core", "mateu_dtos", "mateu_fastapi"}
51
+ _PLATFORM_MODULES = {"builtins", "typing", "abc", "collections.abc", "enum"}
52
+
53
+
54
+ class MateuForbiddenException(Exception):
55
+ """A request the caller may not perform: an action gated by ``disabled_unless``/``audience``
56
+ the caller does not satisfy. The FastAPI adapter answers it with HTTP 403
57
+ and a generic body; the reason is only logged server-side."""
58
+
59
+
60
+ def _is_framework_class(klass) -> bool:
61
+ module = getattr(klass, "__module__", "") or ""
62
+ return (
63
+ klass is object
64
+ or module in _PLATFORM_MODULES
65
+ or module.split(".")[0] in _FRAMEWORK_PACKAGES
66
+ )
67
+
68
+
69
+ def _members(type_):
70
+ """``(name, value, declaring class)`` for every member the instance's attribute lookup would
71
+ resolve (the first definition along the MRO wins), skipping ``_``-prefixed names."""
72
+ seen: set[str] = set()
73
+ for klass in type_.__mro__:
74
+ for name, val in vars(klass).items():
75
+ if name in seen:
76
+ continue
77
+ seen.add(name)
78
+ if name.startswith("_"):
79
+ continue
80
+ yield name, val, klass
81
+
82
+
83
+ def _has_marker(fn, markers) -> bool:
84
+ return any(hasattr(fn, m) for m in markers)
85
+
86
+
87
+ def resolve_action(type_, action_id: str | None, advertised: Callable[[], set[str]]):
88
+ """The function ``action_id`` may invoke on a view of ``type_``, or ``None`` when no
89
+ declared action has that id (see the module docstring for the recognition rule)."""
90
+ if not action_id or action_id.startswith("_"):
91
+ return None
92
+ ids: set[str] | None = None
93
+ for name, val, klass in _members(type_):
94
+ if camel_case(name) != action_id or not inspect.isfunction(val):
95
+ continue
96
+ if _has_marker(val, ACTION_MARKERS):
97
+ return val
98
+ if _is_framework_class(klass):
99
+ continue
100
+ if ids is None:
101
+ ids = advertised()
102
+ if action_id in ids:
103
+ return val
104
+ return None
105
+
106
+
107
+ GROUP_ACTION_MARKER = "__mateu_group_action__"
108
+
109
+
110
+ def resolve_row_action(type_, name: str | None):
111
+ """The ``@list_toolbar_button`` (bulk) or ``@group_action`` (group header) function an
112
+ ``action-on-row-{name}`` may invoke, or None."""
113
+ if not name or name.startswith("_"):
114
+ return None
115
+ for member, val, _klass in _members(type_):
116
+ if (
117
+ camel_case(member) == name
118
+ and inspect.isfunction(val)
119
+ and (hasattr(val, ROW_ACTION_MARKER) or hasattr(val, GROUP_ACTION_MARKER))
120
+ ):
121
+ return val
122
+ return None
123
+
124
+
125
+ def ensure_may_invoke(mapper, type_, fn, action_id: str | None) -> None:
126
+ """Enforces the access decorators of a resolved action at invocation: the render path only
127
+ disables/hides the button, the wire can still name the action."""
128
+ from .mapper import for_current_audience
129
+
130
+ reason = None
131
+ if not mapper.authorized(getattr(fn, "__mateu_eyes_only__", None)):
132
+ reason = "eyes_only"
133
+ elif not class_access_granted(mapper, type_):
134
+ reason = "class-level eyes_only/secured"
135
+ elif not mapper.authorized(getattr(fn, "__mateu_disabled_unless__", None)):
136
+ reason = "disabled_unless"
137
+ elif not for_current_audience(getattr(fn, "__mateu_audience__", None)):
138
+ reason = "audience"
139
+ if reason is not None:
140
+ deny(f"action '{action_id}' on {type_.__module__}.{type_.__qualname__} denied by {reason}")
141
+
142
+
143
+ def class_gates(klass) -> list:
144
+ """The class-level access gates declared on ``klass`` itself: its ``@eyes_only`` and the
145
+ deprecated ``@secured`` (enforced since 3.0-alpha.412 as ``@eyes_only(permissions=[p])``)."""
146
+ return [
147
+ gate
148
+ for gate in (
149
+ klass.__dict__.get("__mateu_eyes_only__"),
150
+ klass.__dict__.get("__mateu_secured_gate__"),
151
+ )
152
+ if gate is not None
153
+ ]
154
+
155
+
156
+ def class_access_granted(mapper, type_) -> bool:
157
+ """Whether the caller passes every class-level ``@eyes_only`` / ``@secured`` of ``type_`` and
158
+ of its bases (Java's ``WireTypePolicy.classLevelAccessGranted``)."""
159
+ if not isinstance(type_, type):
160
+ return True
161
+ for klass in type_.__mro__:
162
+ if any(not mapper.authorized(gate) for gate in class_gates(klass)):
163
+ return False
164
+ return True
165
+
166
+
167
+ def ensure_class_access(mapper, type_, what: str = "request") -> None:
168
+ """Refuses (403) a request that names a type the caller may not see."""
169
+ if type_ is not None and not class_access_granted(mapper, type_):
170
+ deny(
171
+ f"{what} for {type_.__module__}.{type_.__qualname__}: "
172
+ "class-level eyes_only/secured not satisfied"
173
+ )
174
+
175
+
176
+ def deny(what: str) -> None:
177
+ log.warning("Mateu request denied: %s — rejected with 403, the action did not run", what)
178
+ raise MateuForbiddenException(f"Forbidden: {what}")
179
+
180
+
181
+ def may_write(mapper, f) -> bool:
182
+ """Whether the wire may WRITE field ``f``: an ``EyesOnly`` field the caller cannot see or a
183
+ ``ReadOnlyUnless`` field the caller cannot edit is dropped, keeping the server-side value
184
+ (mirrors Java's Hydrater)."""
185
+ return mapper.authorized(f.marker(EyesOnly)) and mapper.authorized(f.marker(ReadOnlyUnless))
186
+
187
+
188
+ def advertised_ids(mapper, type_, instance, layout_override=None) -> set[str]:
189
+ """Every action id the view advertises besides its marked methods — the ids the mapper puts
190
+ on the wire (or the client sends back from the rendered tree) that route to a view method."""
191
+ ids: set[str] = set()
192
+ try:
193
+ fields = view_fields(type_)
194
+ except Exception as e: # noqa: BLE001 - logged, not fatal
195
+ log.warning("advertised_ids failed, falling back (%s)", e)
196
+ fields = []
197
+ for f in fields:
198
+ on_row = f.marker(OnRowSelected)
199
+ if on_row is not None and on_row.value:
200
+ ids.add(camel_case(on_row.value))
201
+ for _event, act in getattr(type_, "__mateu_subscriptions__", ()) or ():
202
+ if act:
203
+ ids.add(camel_case(act))
204
+ # Section(add_action / edit_action / view_more_action) affordance buttons
205
+ from .mapper.layout import section_affordance_ids
206
+
207
+ ids.update(section_affordance_ids(type_))
208
+ # @app_shortcut(action_id=...) on an app class: the shortcut runs that app-level method
209
+ for _key, _route, shortcut_action, _label, _code in getattr(type_, "__mateu_app_shortcuts__", ()) or ():
210
+ if shortcut_action:
211
+ ids.add(shortcut_action)
212
+ refresh = getattr(type_, "__mateu_refresh_action__", None)
213
+ if isinstance(refresh, str) and refresh:
214
+ ids.add(refresh)
215
+
216
+ seen: set[int] = set()
217
+
218
+ def collect(source: Callable[[], Any]) -> None:
219
+ try:
220
+ root = source()
221
+ except Exception as e: # noqa: BLE001 - logged, not fatal
222
+ log.warning("collect failed, falling back (%s)", e)
223
+ return
224
+ _walk(root, ids, seen, 0)
225
+
226
+ collect(lambda: layout_override if layout_override is not None else mapper.component_tree(instance))
227
+ collect(lambda: instance.rules() if isinstance(instance, RuleSupplier) else None)
228
+ collect(lambda: instance.app_actions() if isinstance(instance, AppActionsSupplier) else None)
229
+ # an app's menu: a rule leaf's RunAction names a method of the app (the shell dispatches it on
230
+ # the app-level rail, like a header action) — Java's MenuRuleActions
231
+ collect(lambda: mapper.map_app(type_) if "__mateu_app__" in getattr(type_, "__dict__", {}) else None)
232
+ return ids
233
+
234
+
235
+ def _is_walkable(node) -> bool:
236
+ if isinstance(node, fluent.Component):
237
+ return True
238
+ module = getattr(type(node), "__module__", "") or ""
239
+ return module.split(".")[0] in _FRAMEWORK_PACKAGES
240
+
241
+
242
+ def _walk(node, ids: set[str], seen: set[int], depth: int) -> None:
243
+ """Collects every string value held under a name ending in ``action_id`` (snake) or
244
+ ``ActionId`` (wire dict keys) anywhere in a fluent tree / mapped component graph."""
245
+ if node is None or depth > 64 or isinstance(node, (str, bytes, int, float, bool)):
246
+ return
247
+ if id(node) in seen:
248
+ return
249
+ seen.add(id(node))
250
+ if isinstance(node, dict):
251
+ for k, v in node.items():
252
+ if isinstance(v, str):
253
+ if isinstance(k, str) and v and (k.endswith("action_id") or k.endswith("ActionId")):
254
+ ids.add(v)
255
+ else:
256
+ _walk(v, ids, seen, depth + 1)
257
+ return
258
+ if isinstance(node, (list, tuple, set, frozenset)):
259
+ for item in node:
260
+ _walk(item, ids, seen, depth + 1)
261
+ return
262
+ if not _is_walkable(node):
263
+ return
264
+ items: Any
265
+ if dataclasses.is_dataclass(node):
266
+ items = ((f.name, getattr(node, f.name, None)) for f in dataclasses.fields(node))
267
+ elif hasattr(type(node), "model_fields"):
268
+ items = ((name, getattr(node, name, None)) for name in type(node).model_fields)
269
+ else:
270
+ items = list(getattr(node, "__dict__", {}).items())
271
+ for name, value in items:
272
+ if isinstance(value, str):
273
+ if value and name.endswith("action_id"):
274
+ ids.add(value)
275
+ else:
276
+ _walk(value, ids, seen, depth + 1)
@@ -0,0 +1,319 @@
1
+ """The app's ACTION catalogue — named, client-runnable actions (flows and REST calls) declared once
2
+ and run by id from the shell menu or any page (the Python port of Java's ``ActionRegistry`` /
3
+ ``ActionCatalogMapper``).
4
+
5
+ Two producers, one table: the DERIVED half is whatever ``ActionCatalogSupplier`` classes return; the
6
+ AUTHORED half is ``specs/ui/actions.yaml`` plus any other YAML under the specs directory declaring
7
+ ``type: Actions`` (``MATEU_SPECS_DIR``), merged on top — authored wins, replacing outright. Ids are
8
+ global. Only client-runnable entries are kept (steps or a rest action); anything else is server
9
+ logic, which stays a view method — dropped with a warning naming it.
10
+
11
+ Never fails: a broken file or supplier logs and yields fewer entries.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import os
18
+ import threading
19
+ from collections.abc import Iterable
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ import yaml
24
+
25
+ from mateu_dtos import Action, RestAction, UICommand
26
+ from mateu_uidl.action_catalog import ActionCatalogSupplier, CatalogAction, CatalogRestAction
27
+ from mateu_uidl.flow import CloseOverlay, Emit, FlowStep, MarkClean, MarkDirty, Navigate, RunAction
28
+
29
+ from .rest_source_registry import entry_of, source_dto
30
+
31
+ log = logging.getLogger("mateu.actions")
32
+
33
+ FILE = "actions.yaml"
34
+ TYPE = "Actions"
35
+
36
+
37
+ class ActionRegistry:
38
+ def __init__(self, directory: str | None = None, suppliers: list[type] | None = None) -> None:
39
+ self._dir = Path(directory or os.environ.get("MATEU_SPECS_DIR") or Path("specs") / "ui")
40
+ self._suppliers = list(suppliers or [])
41
+ self._catalog: list[CatalogAction] | None = None
42
+ #: id → ``access:`` of an AUTHORED entry (Java's ``accessById``). Enforced like a page's
43
+ #: declared action: not shipped to a caller who does not satisfy it (it runs in the
44
+ #: browser, so not shipping it IS the enforcement), buttons naming it disabled, and a call
45
+ #: that reaches the server anyway refused with 403.
46
+ self._access_by_id: dict[str, Any] = {}
47
+ self._lock = threading.Lock()
48
+ from mateu_core import dev_specs
49
+
50
+ dev_specs.register(self)
51
+
52
+ def invalidate_specs(self) -> None:
53
+ """Dev mode: a spec changed — the catalogue is read again on next use."""
54
+ self._catalog = None
55
+ self._access_by_id.clear()
56
+
57
+ def catalog(self) -> list[CatalogAction]:
58
+ """The merged catalogue (authored over derived), loaded once."""
59
+ if self._catalog is None:
60
+ with self._lock:
61
+ if self._catalog is None:
62
+ self._catalog = merged_over(self.authored(), self.derived())
63
+ return self._catalog
64
+
65
+ def get(self, action_id: str | None) -> CatalogAction | None:
66
+ if not action_id:
67
+ return None
68
+ return next((a for a in self.catalog() if a.id == action_id.strip()), None)
69
+
70
+ def derived(self) -> list[CatalogAction]:
71
+ out: list[CatalogAction] = []
72
+ for supplier in self._suppliers:
73
+ try:
74
+ contributed = supplier().action_catalog() or []
75
+ except Exception as e: # noqa: BLE001 - a failing supplier contributes nothing
76
+ log.warning("Action catalogue: %s failed (%s)", supplier.__name__, e)
77
+ continue
78
+ out.extend(a for a in contributed if isinstance(a, CatalogAction) and a.id)
79
+ return merged_over(client_runnable_only(out, "a supplier"), [])
80
+
81
+ def authored(self) -> list[CatalogAction]:
82
+ if not self._dir.is_dir():
83
+ return []
84
+ conventional = self._dir / FILE
85
+ files = [conventional] if conventional.is_file() else []
86
+ for path in sorted(p for p in self._dir.rglob("*") if p.suffix in (".yaml", ".yml")):
87
+ if path.resolve() == conventional.resolve():
88
+ continue
89
+ root = _load(path)
90
+ if isinstance(root, dict) and root.get("type") == TYPE:
91
+ files.append(path)
92
+ by_id: dict[str, CatalogAction] = {}
93
+ access_by_id: dict[str, Any] = {}
94
+ for path in files:
95
+ for action in _read(path, access_by_id):
96
+ by_id[action.id] = action
97
+ self._access_by_id = {k: v for k, v in access_by_id.items() if k in by_id}
98
+ return list(by_id.values())
99
+
100
+ # ── access (``access:`` on an entry) ──────────────────────────────────────
101
+ def access_of(self, action_id: str | None):
102
+ """The restriction of catalogue action ``action_id``, or None."""
103
+ self.catalog()
104
+ return self._access_by_id.get(action_id) if action_id else None
105
+
106
+ def grants(self, action_id: str | None, authorized) -> bool:
107
+ """Whether the caller (``authorized``: the mapper's gate check) may run ``action_id``."""
108
+ access = self.access_of(action_id)
109
+ return access is None or bool(authorized(access))
110
+
111
+ def refused_for(self, authorized) -> set[str]:
112
+ """The ids of the restricted catalogue actions the caller may NOT run."""
113
+ self.catalog()
114
+ if authorized is None:
115
+ return set()
116
+ return {i for i, access in self._access_by_id.items() if not authorized(access)}
117
+
118
+ def restricted_ids(self) -> set[str]:
119
+ self.catalog()
120
+ return set(self._access_by_id)
121
+
122
+ def restricts_any(self) -> bool:
123
+ self.catalog()
124
+ return bool(self._access_by_id)
125
+
126
+ def catalog_for(self, authorized) -> list[CatalogAction]:
127
+ """The catalogue without the entries the caller may not run."""
128
+ all_ = self.catalog()
129
+ if not self._access_by_id or authorized is None:
130
+ return all_
131
+ refused = self.refused_for(authorized)
132
+ return [a for a in all_ if a.id not in refused]
133
+
134
+ def wire(self, authorized=None) -> list[Action]:
135
+ """The catalogue on the wire (AppMetadata.actionCatalogue), flows lowered to commands —
136
+ without the entries the caller (``authorized``) may not run."""
137
+ return [to_dto(a) for a in self.catalog_for(authorized)]
138
+
139
+ def referenced_by(
140
+ self, referenced: Iterable[str], owned: Iterable[str], authorized=None
141
+ ) -> list[CatalogAction]:
142
+ """The catalogue entries an owner needs to carry: each referenced id it does NOT own, closed
143
+ transitively over RunAction steps. OWNER FIRST: an owned id is never replaced. With
144
+ ``authorized``, the entries the caller may not run are left out (nor followed)."""
145
+ known = set(owned)
146
+ if authorized is not None and self.restricts_any():
147
+ known |= self.refused_for(authorized) # treated as owned: never added, never followed
148
+ found: list[CatalogAction] = []
149
+ pending = list(referenced)
150
+ while pending:
151
+ action_id = pending.pop(0)
152
+ entry = self.get(action_id) if action_id not in known else None
153
+ if entry is None:
154
+ continue
155
+ known.add(action_id)
156
+ found.append(entry)
157
+ pending.extend(s.action_id for s in entry.steps if isinstance(s, RunAction))
158
+ return found
159
+
160
+
161
+ def merged_over(authored: list[CatalogAction], derived: list[CatalogAction]) -> list[CatalogAction]:
162
+ """Authored over derived, keyed by id: an authored entry replaces the derived one in place."""
163
+ by_id: dict[str, CatalogAction] = {a.id: a for a in derived}
164
+ for a in authored:
165
+ by_id[a.id] = a
166
+ return list(by_id.values())
167
+
168
+
169
+ def client_runnable_only(actions: Iterable[CatalogAction], origin: Any) -> list[CatalogAction]:
170
+ accepted = []
171
+ for action in actions:
172
+ if action.client_runnable():
173
+ accepted.append(action)
174
+ else:
175
+ log.warning(
176
+ "Action catalogue: '%s' in %s is not client-runnable (it has neither steps nor a"
177
+ " restAction) and is ignored — server logic stays a view method",
178
+ action.id,
179
+ origin,
180
+ )
181
+ return accepted
182
+
183
+
184
+ def _load(path: Path) -> Any:
185
+ try:
186
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
187
+ except Exception: # noqa: BLE001
188
+ return None
189
+
190
+
191
+ def collect_ids(node: Any, out: set[str] | None = None) -> set[str]:
192
+ """Every ``actionId`` / ``*ActionId`` string a raw YAML tree names (Java's
193
+ ``ActionRegistry.collectIds``)."""
194
+ out = set() if out is None else out
195
+ if isinstance(node, dict):
196
+ for name, value in node.items():
197
+ if isinstance(value, str) and (name == "actionId" or name.endswith("ActionId")):
198
+ if value.strip():
199
+ out.add(value)
200
+ else:
201
+ collect_ids(value, out)
202
+ elif isinstance(node, list):
203
+ for child in node:
204
+ collect_ids(child, out)
205
+ return out
206
+
207
+
208
+ def catalogue_ids_named_by(tree: Any) -> set[str]:
209
+ """The action ids ``tree`` names that its own ``actions:`` do not declare — OWNER FIRST: an id
210
+ the page declares is the page's, never the catalogue's (Java's ``catalogueIdsNamedBy``)."""
211
+ ids = collect_ids(tree)
212
+ own = tree.get("actions") if isinstance(tree, dict) else None
213
+ if isinstance(own, list):
214
+ for action in own:
215
+ if isinstance(action, dict):
216
+ ids.discard(str(action.get("id")))
217
+ return ids
218
+
219
+
220
+ def _read(path: Path, access_by_id: dict[str, Any] | None = None) -> list[CatalogAction]:
221
+ from .yaml_access import access_of
222
+
223
+ root = _load(path)
224
+ nodes = root.get("actions") if isinstance(root, dict) else root
225
+ if not isinstance(nodes, list):
226
+ return []
227
+ parsed = []
228
+ for node in nodes:
229
+ if not isinstance(node, dict):
230
+ continue
231
+ action_id = str(node.get("id") or "").strip()
232
+ if not action_id:
233
+ log.warning("Ignoring an action with no id in %s", path)
234
+ continue
235
+ if access_by_id is not None:
236
+ access = access_of(node.get("access"))
237
+ if access is not None:
238
+ access_by_id[action_id] = access
239
+ else:
240
+ access_by_id.pop(action_id, None) # a later file redeclaring it without access
241
+ parsed.append(
242
+ CatalogAction(
243
+ id=action_id,
244
+ description=str(node.get("description") or ""),
245
+ steps=tuple(s for s in (_step(n) for n in node.get("steps") or []) if s is not None),
246
+ rest_action=_rest_action(node.get("restAction")),
247
+ )
248
+ )
249
+ return client_runnable_only(parsed, path)
250
+
251
+
252
+ def _step(node: Any) -> FlowStep | None:
253
+ if not isinstance(node, dict):
254
+ return None
255
+ kind = node.get("type")
256
+ if kind == "Navigate":
257
+ return Navigate(str(node.get("route") or ""))
258
+ if kind == "Emit":
259
+ return Emit(str(node.get("event") or ""), node.get("payload"))
260
+ if kind == "CloseOverlay":
261
+ return CloseOverlay(node.get("event"))
262
+ if kind == "RunAction":
263
+ return RunAction(str(node.get("actionId") or ""))
264
+ if kind == "MarkClean":
265
+ return MarkClean()
266
+ if kind == "MarkDirty":
267
+ return MarkDirty()
268
+ log.warning("Ignoring a flow step of unknown type '%s'", kind)
269
+ return None
270
+
271
+
272
+ def _rest_action(node: Any) -> CatalogRestAction | None:
273
+ if not isinstance(node, dict):
274
+ return None
275
+ # reuse the source reader of the REST catalogue (an entry nests its request under `source:`)
276
+ entry = entry_of({"name": "_", "source": node.get("source")})
277
+ if entry is None:
278
+ return None
279
+ return CatalogRestAction(
280
+ source=entry.source,
281
+ success_message=node.get("successMessage"),
282
+ result_path=node.get("resultPath"),
283
+ )
284
+
285
+
286
+ def lower(step: FlowStep) -> UICommand:
287
+ """A step as the one wire command it is, with no target (Java's ActionDtoMapper: the client
288
+ applies it on the component that fired the action)."""
289
+ return step.to_command().model_copy(update={"target_component_id": None})
290
+
291
+
292
+ def to_dto(action: CatalogAction) -> Action:
293
+ """One catalogue entry on the wire (Java's ActionDtoMapper.mapAction)."""
294
+ rest = action.rest_action
295
+ return Action(
296
+ id=action.id,
297
+ validation_required=False,
298
+ rest_action=(
299
+ RestAction(
300
+ source=source_dto(rest.source),
301
+ success_message=rest.success_message,
302
+ result_path=rest.result_path,
303
+ )
304
+ if rest is not None
305
+ else None
306
+ ),
307
+ commands=[lower(s) for s in action.steps] or None,
308
+ )
309
+
310
+
311
+ __all__ = [
312
+ "ActionRegistry",
313
+ "ActionCatalogSupplier",
314
+ "catalogue_ids_named_by",
315
+ "collect_ids",
316
+ "merged_over",
317
+ "to_dto",
318
+ "lower",
319
+ ]
@@ -0,0 +1,49 @@
1
+ """The capability vocabulary — the Python mirror of Java's ``io.mateu.uidl.Capabilities``.
2
+
3
+ An app advertises, on ``AppMetadata.requiredCapabilities``, the tokens it REQUIRES from whatever
4
+ renderer/shell hosts it; a renderer advertises what it PROVIDES. Compatibility across the embedding
5
+ boundary is negotiated BY CAPABILITY, not by a fixed version window. Most tokens are DERIVED from
6
+ the app's own metadata; a developer can also DECLARE extra ones via ``@app(requires=[...])``.
7
+
8
+ The token strings are the contract shared with the frontend (``libs/mateu`` capabilities.ts), the
9
+ Java backend and the .NET port — keep them in step. Adding a capability is additive; never rename
10
+ or repurpose an existing token.
11
+ """
12
+
13
+ #: Server-sent events: long-running actions stream, and/or the AI chat endpoint.
14
+ SSE = "sse"
15
+
16
+ #: An app-scope data source fetched once on boot (AppMetadata.appDataSource).
17
+ APP_DATA = "app-data"
18
+
19
+ #: A named REST source catalogue the surfaces reference by ref.
20
+ REST_SOURCES = "rest-sources"
21
+
22
+ #: The command-center palette FAB (Ask-Oracle pattern).
23
+ COMMAND_CENTER = "command-center"
24
+
25
+ #: Global entity search wired into the ⌘K palette / command center.
26
+ GLOBAL_SEARCH = "global-search"
27
+
28
+ #: The notification inbox bell.
29
+ NOTIFICATIONS = "notifications"
30
+
31
+ #: App-header context selectors (@app_context).
32
+ CONTEXT_SELECTORS = "context-selectors"
33
+
34
+ #: App-header action buttons (AppActionsSupplier).
35
+ HEADER_ACTIONS = "header-actions"
36
+
37
+ #: Every token this build knows about — the set a full renderer PROVIDES.
38
+ ALL = frozenset(
39
+ {
40
+ SSE,
41
+ APP_DATA,
42
+ REST_SOURCES,
43
+ COMMAND_CENTER,
44
+ GLOBAL_SEARCH,
45
+ NOTIFICATIONS,
46
+ CONTEXT_SELECTORS,
47
+ HEADER_ACTIONS,
48
+ }
49
+ )