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.
- mateu_core/__init__.py +24 -0
- mateu_core/_version.py +8 -0
- mateu_core/action_guard.py +276 -0
- mateu_core/action_registry.py +319 -0
- mateu_core/capabilities.py +49 -0
- mateu_core/component_registry.py +123 -0
- mateu_core/dev_specs.py +223 -0
- mateu_core/documents.py +203 -0
- mateu_core/environments.py +176 -0
- mateu_core/error_boundary.py +77 -0
- mateu_core/errors.py +40 -0
- mateu_core/export.py +56 -0
- mateu_core/field_type_registry.py +223 -0
- mateu_core/identity.py +92 -0
- mateu_core/importwizard.py +389 -0
- mateu_core/inference_switches.py +36 -0
- mateu_core/islands.py +93 -0
- mateu_core/labels_aside_inference.py +73 -0
- mateu_core/layout_delta.py +196 -0
- mateu_core/layout_inference.py +77 -0
- mateu_core/mapper/__init__.py +368 -0
- mateu_core/mapper/_base.py +16 -0
- mateu_core/mapper/_common.py +447 -0
- mateu_core/mapper/app.py +603 -0
- mateu_core/mapper/archetypes.py +233 -0
- mateu_core/mapper/components.py +1295 -0
- mateu_core/mapper/crud.py +759 -0
- mateu_core/mapper/decorations.py +154 -0
- mateu_core/mapper/fields.py +720 -0
- mateu_core/mapper/layout.py +624 -0
- mateu_core/mapper/view.py +291 -0
- mateu_core/mapper/wizard.py +262 -0
- mateu_core/mcp.py +318 -0
- mateu_core/menu_route_conformance.py +55 -0
- mateu_core/mobile_manifest.py +94 -0
- mateu_core/naming.py +58 -0
- mateu_core/page_inference.py +63 -0
- mateu_core/page_type_inference.py +72 -0
- mateu_core/partial_registry.py +80 -0
- mateu_core/py.typed +0 -0
- mateu_core/reflection.py +103 -0
- mateu_core/registry.py +233 -0
- mateu_core/request_context.py +81 -0
- mateu_core/rest_source_registry.py +280 -0
- mateu_core/route_registry.py +545 -0
- mateu_core/sample_sources.py +27 -0
- mateu_core/sync_handler/__init__.py +273 -0
- mateu_core/sync_handler/_base.py +16 -0
- mateu_core/sync_handler/_common.py +161 -0
- mateu_core/sync_handler/actions.py +197 -0
- mateu_core/sync_handler/app.py +187 -0
- mateu_core/sync_handler/contract.py +76 -0
- mateu_core/sync_handler/crud.py +561 -0
- mateu_core/sync_handler/dispatch.py +466 -0
- mateu_core/sync_handler/grid_field.py +307 -0
- mateu_core/sync_handler/helpers.py +268 -0
- mateu_core/sync_handler/listing.py +275 -0
- mateu_core/sync_handler/proxy.py +164 -0
- mateu_core/sync_handler/render.py +69 -0
- mateu_core/sync_handler/search.py +578 -0
- mateu_core/sync_handler/wizard.py +161 -0
- mateu_core/translations.py +247 -0
- mateu_core/url_template.py +82 -0
- mateu_core/validation.py +215 -0
- mateu_core/yaml_access.py +210 -0
- mateu_core/yaml_preview.py +458 -0
- mateu_core/yaml_spec_loader.py +251 -0
- mateu_dtos/__init__.py +219 -0
- mateu_dtos/app.py +160 -0
- mateu_dtos/base.py +20 -0
- mateu_dtos/basic.py +203 -0
- mateu_dtos/components.py +284 -0
- mateu_dtos/dashboard.py +212 -0
- mateu_dtos/envelope.py +145 -0
- mateu_dtos/fields.py +217 -0
- mateu_dtos/front_office.py +403 -0
- mateu_dtos/layout.py +155 -0
- mateu_dtos/overlays.py +89 -0
- mateu_dtos/py.typed +0 -0
- mateu_dtos/records.py +261 -0
- mateu_dtos/ux.py +328 -0
- mateu_fastapi/__init__.py +401 -0
- mateu_fastapi/py.typed +0 -0
- mateu_ui-3.0.0a418.dist-info/METADATA +278 -0
- mateu_ui-3.0.0a418.dist-info/RECORD +113 -0
- mateu_ui-3.0.0a418.dist-info/WHEEL +4 -0
- mateu_uidl/__init__.py +372 -0
- mateu_uidl/action_catalog.py +51 -0
- mateu_uidl/adapters.py +64 -0
- mateu_uidl/archetype_pages.py +600 -0
- mateu_uidl/archetypes.py +282 -0
- mateu_uidl/business_components.py +46 -0
- mateu_uidl/class_decorators.py +695 -0
- mateu_uidl/components.py +2033 -0
- mateu_uidl/constraints.py +100 -0
- mateu_uidl/documents.py +81 -0
- mateu_uidl/environments.py +35 -0
- mateu_uidl/export.py +130 -0
- mateu_uidl/field_types.py +116 -0
- mateu_uidl/flow.py +107 -0
- mateu_uidl/i18n.py +29 -0
- mateu_uidl/listing.py +337 -0
- mateu_uidl/live_header_values.py +62 -0
- mateu_uidl/markers.py +327 -0
- mateu_uidl/messages.py +81 -0
- mateu_uidl/method_decorators.py +231 -0
- mateu_uidl/patterns.py +216 -0
- mateu_uidl/placement.py +107 -0
- mateu_uidl/py.typed +0 -0
- mateu_uidl/rest_sources.py +207 -0
- mateu_uidl/security.py +135 -0
- mateu_uidl/suppliers.py +249 -0
- 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
|
+
)
|