terp-cli 0.1.0__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.
- terp/cli/__init__.py +1633 -0
- terp/cli/_appref.py +43 -0
- terp/cli/access.py +482 -0
- terp/cli/apidocs.py +132 -0
- terp/cli/dev.py +145 -0
- terp/cli/docker.py +57 -0
- terp/cli/jobs.py +238 -0
- terp/cli/openapi.py +67 -0
- terp/cli/profiles.py +148 -0
- terp/cli/py.typed +0 -0
- terp/cli/scaffold.py +343 -0
- terp/cli/schema.py +317 -0
- terp/cli/seed.py +67 -0
- terp/cli/users.py +94 -0
- terp/cli/verify.py +469 -0
- terp_cli-0.1.0.dist-info/METADATA +16 -0
- terp_cli-0.1.0.dist-info/RECORD +19 -0
- terp_cli-0.1.0.dist-info/WHEEL +4 -0
- terp_cli-0.1.0.dist-info/entry_points.txt +2 -0
terp/cli/_appref.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Shared CLI helper: resolve a ``module:attribute`` app reference and prime ``sys.path``.
|
|
2
|
+
|
|
3
|
+
Several ``terp`` subcommands (``seed``, ``user create``, …) build the app before they touch
|
|
4
|
+
the database, because building it runs ``create_app`` — which configures the engine, the
|
|
5
|
+
durable audit sink, and the job / event catalogs. Centralising that resolution keeps every
|
|
6
|
+
command on one loader (mirrors the ``terp openapi`` / ``terp jobs`` loaders).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import importlib
|
|
12
|
+
import pathlib
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from fastapi import FastAPI
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def push_app_root(app_root: str | pathlib.Path) -> None:
|
|
19
|
+
"""Place *app_root* first on ``sys.path`` so the app package imports as a console script."""
|
|
20
|
+
root = str(pathlib.Path(app_root).resolve())
|
|
21
|
+
if root not in sys.path:
|
|
22
|
+
sys.path.insert(0, root)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_app(dotted: str) -> FastAPI:
|
|
26
|
+
"""Resolve ``module:attribute`` to a FastAPI app (an instance or a zero-arg factory).
|
|
27
|
+
|
|
28
|
+
Building the app runs ``create_app``, so the engine, the durable audit sink, and the
|
|
29
|
+
catalogs are configured before the command opens a session. A bad reference fails closed
|
|
30
|
+
with a clean :class:`SystemExit`.
|
|
31
|
+
"""
|
|
32
|
+
module_name, _, attr = dotted.partition(":")
|
|
33
|
+
if not module_name:
|
|
34
|
+
raise SystemExit(f"{dotted!r} is not a valid 'module:attribute' reference")
|
|
35
|
+
module = importlib.import_module(module_name)
|
|
36
|
+
candidate = getattr(module, attr or "app")
|
|
37
|
+
if isinstance(candidate, FastAPI):
|
|
38
|
+
return candidate
|
|
39
|
+
if callable(candidate):
|
|
40
|
+
built = candidate()
|
|
41
|
+
if isinstance(built, FastAPI):
|
|
42
|
+
return built
|
|
43
|
+
raise SystemExit(f"{dotted!r} did not resolve to a FastAPI application")
|
terp/cli/access.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""``terp inspect access`` — the Access Graph: one view of who can reach what.
|
|
2
|
+
|
|
3
|
+
The effective permission story has three layers, and each already has a typed,
|
|
4
|
+
enforced source of truth:
|
|
5
|
+
|
|
6
|
+
1. **Module access** — ``ModuleSpec.policy`` (deny-by-default; ``Policy.public``
|
|
7
|
+
is the justified exception).
|
|
8
|
+
2. **Endpoint access** — the policy's read/write requirement applied per HTTP
|
|
9
|
+
method by the kernel guard, plus any route-level
|
|
10
|
+
``require_permission(...)`` dependency the access capability contributes.
|
|
11
|
+
3. **Data visibility / object authority** — model traits (``SoftDeleteMixin``,
|
|
12
|
+
``OwnedMixin``, ``TenantScopedMixin``, ``ActorStampedMixin``) honored
|
|
13
|
+
centrally by ``BaseService`` and the registered scope / object-authz
|
|
14
|
+
predicates (ADR 0017 / 0029).
|
|
15
|
+
|
|
16
|
+
This module *projects* those sources into one structured report — JSON-first so
|
|
17
|
+
external tooling (Terp Studio) can visualize the full access graph without
|
|
18
|
+
importing ``terp.*`` — plus a human-readable text rendering. It is a **view,
|
|
19
|
+
never a second source of truth** (ADR 0011): nothing here configures anything.
|
|
20
|
+
|
|
21
|
+
Data-layer visibility requires the module to declare its services on the spec
|
|
22
|
+
(``ModuleSpec(services=(JournalService,))``). A module with a router but no
|
|
23
|
+
declared services is reported with an explicit warning ("data access not
|
|
24
|
+
visualizable"), fail-visible rather than silently incomplete.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
from collections.abc import Sequence
|
|
31
|
+
|
|
32
|
+
from fastapi import FastAPI
|
|
33
|
+
from fastapi.routing import APIRoute
|
|
34
|
+
from starlette.routing import Mount, Route
|
|
35
|
+
|
|
36
|
+
from terp.core import (
|
|
37
|
+
ActorStampedMixin,
|
|
38
|
+
ControlPlane,
|
|
39
|
+
ModuleSpec,
|
|
40
|
+
OwnedMixin,
|
|
41
|
+
SoftDeleteMixin,
|
|
42
|
+
)
|
|
43
|
+
from terp.core.object_authz import registered_object_authz_predicates
|
|
44
|
+
from terp.core.scoping import registered_scope_predicates
|
|
45
|
+
|
|
46
|
+
# Mirrors the kernel guard's method split (terp.core.app): any other method is a read.
|
|
47
|
+
_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
|
|
48
|
+
|
|
49
|
+
# Every module mounts under this prefix (``create_app``); a served path outside it is a
|
|
50
|
+
# kernel / open route (e.g. health), not part of any module's policy surface.
|
|
51
|
+
_API_PREFIX = "/api/v1/"
|
|
52
|
+
|
|
53
|
+
# OpenAPI path-item keys that are HTTP operations (the rest are metadata such as
|
|
54
|
+
# ``parameters`` / ``summary``) — used to read the served methods for each path.
|
|
55
|
+
_HTTP_METHODS = frozenset(
|
|
56
|
+
{"get", "put", "post", "delete", "options", "head", "patch", "trace"}
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# The attribute the access capability stamps on a ``require_permission(...)``
|
|
60
|
+
# dependency so route-level grants are detectable here (a marker, not a control).
|
|
61
|
+
PERMISSION_DEPENDENCY_ATTR = "__terp_required_permission__"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _policy_json(spec: ModuleSpec) -> dict[str, object] | None:
|
|
65
|
+
"""The module-access layer: the spec's declared ``Policy`` as plain data."""
|
|
66
|
+
policy = spec.policy
|
|
67
|
+
if policy is None:
|
|
68
|
+
return None
|
|
69
|
+
if policy.is_public:
|
|
70
|
+
return {
|
|
71
|
+
"public": True,
|
|
72
|
+
"public_reason": policy.public_reason,
|
|
73
|
+
"allows_public_writes": policy.allows_public_writes,
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
"public": False,
|
|
77
|
+
"authenticated": policy.authenticated,
|
|
78
|
+
"read": policy.read_requirement.label,
|
|
79
|
+
"write": policy.write_requirement.label,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _route_permissions(route: APIRoute) -> list[str]:
|
|
84
|
+
"""Route-level ``require_permission`` names, where the dependency is marked."""
|
|
85
|
+
found: list[str] = []
|
|
86
|
+
for depends in route.dependencies:
|
|
87
|
+
name = getattr(
|
|
88
|
+
getattr(depends, "dependency", None), PERMISSION_DEPENDENCY_ATTR, None
|
|
89
|
+
)
|
|
90
|
+
if isinstance(name, str):
|
|
91
|
+
found.append(name)
|
|
92
|
+
return found
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _endpoint_json(spec: ModuleSpec, route: APIRoute) -> dict[str, object]:
|
|
96
|
+
"""The endpoint-access layer: one mounted route + its effective requirement."""
|
|
97
|
+
methods = sorted(route.methods or ())
|
|
98
|
+
is_write = any(method in _MUTATING_METHODS for method in methods)
|
|
99
|
+
policy = spec.policy
|
|
100
|
+
if policy is None:
|
|
101
|
+
requirement = "denied (no policy declared)"
|
|
102
|
+
elif policy.is_public:
|
|
103
|
+
requirement = "public"
|
|
104
|
+
else:
|
|
105
|
+
requirement = (
|
|
106
|
+
policy.write_requirement.label if is_write else policy.read_requirement.label
|
|
107
|
+
)
|
|
108
|
+
return {
|
|
109
|
+
"path": f"/api/v1/{spec.name}{route.path}",
|
|
110
|
+
"methods": methods,
|
|
111
|
+
"kind": "write" if is_write else "read",
|
|
112
|
+
"requirement": requirement,
|
|
113
|
+
"extra_permissions": _route_permissions(route),
|
|
114
|
+
"name": route.name,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _mro_names(model: type) -> set[str]:
|
|
119
|
+
return {klass.__name__ for klass in model.__mro__}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _model_json(service: type) -> dict[str, object] | None:
|
|
123
|
+
"""The data layer: one declared service's model + its enforced traits.
|
|
124
|
+
|
|
125
|
+
Core traits are detected by ``issubclass``; the tenancy trait by MRO class
|
|
126
|
+
name so this stays capability-agnostic (the CLI never imports
|
|
127
|
+
``terp.capabilities.tenancy``).
|
|
128
|
+
"""
|
|
129
|
+
model = getattr(service, "model", None)
|
|
130
|
+
if not isinstance(model, type):
|
|
131
|
+
return None
|
|
132
|
+
tenant_scoped = "TenantScopedMixin" in _mro_names(model)
|
|
133
|
+
soft_delete = issubclass(model, SoftDeleteMixin)
|
|
134
|
+
owned = issubclass(model, OwnedMixin)
|
|
135
|
+
read_scope: list[str] = []
|
|
136
|
+
if soft_delete:
|
|
137
|
+
read_scope.append("soft-delete")
|
|
138
|
+
if tenant_scoped:
|
|
139
|
+
read_scope.append("tenant")
|
|
140
|
+
write_authority: list[str] = []
|
|
141
|
+
if owned:
|
|
142
|
+
write_authority.append("owner")
|
|
143
|
+
if tenant_scoped:
|
|
144
|
+
write_authority.append("tenant-context")
|
|
145
|
+
return {
|
|
146
|
+
"model": model.__name__,
|
|
147
|
+
"table": getattr(model, "__tablename__", None),
|
|
148
|
+
"service": service.__name__,
|
|
149
|
+
"traits": {
|
|
150
|
+
"soft_delete": soft_delete,
|
|
151
|
+
"owned": owned,
|
|
152
|
+
"tenant_scoped": tenant_scoped,
|
|
153
|
+
"actor_stamped": issubclass(model, ActorStampedMixin),
|
|
154
|
+
},
|
|
155
|
+
"read_scope": read_scope,
|
|
156
|
+
"write_authority": write_authority,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _module_warnings(
|
|
161
|
+
spec: ModuleSpec, models: Sequence[dict[str, object]]
|
|
162
|
+
) -> list[str]:
|
|
163
|
+
"""Honest gaps the Studio must not paper over (fail-visible, never silent)."""
|
|
164
|
+
warnings: list[str] = []
|
|
165
|
+
if spec.policy is None:
|
|
166
|
+
warnings.append(
|
|
167
|
+
"no Policy declared — create_app refuses to mount this module (deny-by-default)"
|
|
168
|
+
)
|
|
169
|
+
if spec.router is not None and not spec.services:
|
|
170
|
+
warnings.append(
|
|
171
|
+
"data access not visualizable: ModuleSpec declares no services — "
|
|
172
|
+
"declare services=(YourService, ...) so the data layer appears here"
|
|
173
|
+
)
|
|
174
|
+
for model in models:
|
|
175
|
+
traits = model["traits"]
|
|
176
|
+
if traits["owned"]: # type: ignore[index]
|
|
177
|
+
warnings.append(
|
|
178
|
+
f"{model['model']}: OwnedMixin gates writes only — reads are not "
|
|
179
|
+
"owner-filtered unless a registered scope predicate narrows them"
|
|
180
|
+
)
|
|
181
|
+
return warnings
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _module_access_json(spec: ModuleSpec) -> dict[str, object]:
|
|
185
|
+
endpoints: list[dict[str, object]] = []
|
|
186
|
+
if spec.router is not None:
|
|
187
|
+
endpoints = [
|
|
188
|
+
_endpoint_json(spec, route)
|
|
189
|
+
for route in spec.router.routes
|
|
190
|
+
if isinstance(route, APIRoute)
|
|
191
|
+
]
|
|
192
|
+
endpoints.sort(key=lambda item: (item["path"], item["methods"]))
|
|
193
|
+
models = [
|
|
194
|
+
entry
|
|
195
|
+
for entry in (_model_json(service) for service in spec.services)
|
|
196
|
+
if entry is not None
|
|
197
|
+
]
|
|
198
|
+
return {
|
|
199
|
+
"name": spec.name,
|
|
200
|
+
"prefix": f"/api/v1/{spec.name}" if spec.router is not None else None,
|
|
201
|
+
"policy": _policy_json(spec),
|
|
202
|
+
"endpoints": endpoints,
|
|
203
|
+
"models": models,
|
|
204
|
+
"warnings": _module_warnings(spec, models),
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_access_graph(
|
|
209
|
+
plane: ControlPlane,
|
|
210
|
+
specs: Sequence[ModuleSpec],
|
|
211
|
+
*,
|
|
212
|
+
kernel_routes: Sequence[dict[str, object]] = (),
|
|
213
|
+
omitted_routes: Sequence[dict[str, object]] = (),
|
|
214
|
+
undeclared_subscribers: Sequence[dict[str, object]] = (),
|
|
215
|
+
) -> dict[str, object]:
|
|
216
|
+
"""The Access Graph as plain data: roles -> modules -> endpoints -> data traits.
|
|
217
|
+
|
|
218
|
+
The stable contract ``terp inspect access --format json`` emits for Studio
|
|
219
|
+
and other external tooling. App-wide registered predicates are reported by
|
|
220
|
+
(qualified) name so a custom row-visibility or object-authz policy is
|
|
221
|
+
*visible* in the graph even though its logic lives in code.
|
|
222
|
+
|
|
223
|
+
``kernel_routes`` (unauthenticated framework routes, e.g. health) and
|
|
224
|
+
``omitted_routes`` (mounted routes the graph does not cover — a leak alarm)
|
|
225
|
+
are supplied by :func:`build_access_graph_for_app`, which reconciles the graph
|
|
226
|
+
against the composed app; both are empty for a hand-passed module list.
|
|
227
|
+
"""
|
|
228
|
+
return {
|
|
229
|
+
"roles": [
|
|
230
|
+
{"name": role.name, "rank": role.rank}
|
|
231
|
+
for role in sorted(plane.permissions.roles, key=lambda item: item.rank)
|
|
232
|
+
],
|
|
233
|
+
"permissions": [
|
|
234
|
+
{"name": permission.name, "min_role": permission.min_role.name}
|
|
235
|
+
for permission in sorted(
|
|
236
|
+
plane.permissions.permissions, key=lambda item: item.name
|
|
237
|
+
)
|
|
238
|
+
],
|
|
239
|
+
"modules": [
|
|
240
|
+
_module_access_json(spec) for spec in sorted(specs, key=lambda s: s.name)
|
|
241
|
+
],
|
|
242
|
+
"scope_predicates": [
|
|
243
|
+
f"{predicate.__module__}.{predicate.__qualname__}"
|
|
244
|
+
for predicate in registered_scope_predicates()
|
|
245
|
+
],
|
|
246
|
+
"object_authz_predicates": [
|
|
247
|
+
f"{predicate.__module__}.{predicate.__qualname__}"
|
|
248
|
+
for predicate in registered_object_authz_predicates()
|
|
249
|
+
],
|
|
250
|
+
"kernel_routes": list(kernel_routes),
|
|
251
|
+
"omitted_routes": list(omitted_routes),
|
|
252
|
+
"undeclared_subscribers": list(undeclared_subscribers),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _undeclared_subscriber_alarms(specs: Sequence[ModuleSpec]) -> list[dict[str, object]]:
|
|
257
|
+
"""Registered event handlers no module declares subscribing to (drift alarm).
|
|
258
|
+
|
|
259
|
+
The eventbus registry holds the ACTUAL handlers; ``ModuleSpec.subscribes`` is the
|
|
260
|
+
declared contract the control-plane view shows. A handler for an event nobody
|
|
261
|
+
declares is executable code reacting to events invisibly — alarmed, never dropped.
|
|
262
|
+
Lazy: an app without the eventbus capability has no registry (empty alarms).
|
|
263
|
+
"""
|
|
264
|
+
try:
|
|
265
|
+
from terp.capabilities.eventbus.registry import registered_handlers
|
|
266
|
+
except ImportError: # pragma: no cover - eventbus not installed
|
|
267
|
+
return []
|
|
268
|
+
declared = {
|
|
269
|
+
event.name for spec in specs for event in spec.subscribes
|
|
270
|
+
}
|
|
271
|
+
return [
|
|
272
|
+
{
|
|
273
|
+
"event": name,
|
|
274
|
+
"handlers": [
|
|
275
|
+
f"{handler.__module__}.{handler.__qualname__}" for handler in handlers
|
|
276
|
+
],
|
|
277
|
+
"detail": "registered handler(s) for an event NO module declares "
|
|
278
|
+
"subscribing to (ModuleSpec.subscribes) -- invisible drift",
|
|
279
|
+
}
|
|
280
|
+
for name, handlers in sorted(registered_handlers().items())
|
|
281
|
+
if name not in declared
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _served_routes(app: FastAPI) -> dict[str, frozenset[str]]:
|
|
286
|
+
"""Every path the composed app serves -> its HTTP methods, from ``app.openapi()``.
|
|
287
|
+
|
|
288
|
+
``app.openapi()`` is the same document ``terp openapi`` exports and FastAPI serves,
|
|
289
|
+
so it is the authoritative, full-path route table — client module routers, every
|
|
290
|
+
discovered capability router, and the kernel health routes — the ground truth the
|
|
291
|
+
access graph is reconciled against.
|
|
292
|
+
"""
|
|
293
|
+
paths: dict[str, object] = app.openapi().get("paths", {})
|
|
294
|
+
return {
|
|
295
|
+
path: frozenset(
|
|
296
|
+
method.upper() for method in item if method.lower() in _HTTP_METHODS
|
|
297
|
+
)
|
|
298
|
+
for path, item in paths.items()
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _schema_hidden_routes(app: FastAPI) -> list[dict[str, object]]:
|
|
303
|
+
"""Reachable top-level routes ``app.openapi()`` does NOT list — still reported.
|
|
304
|
+
|
|
305
|
+
FastAPI's own docs/schema endpoints (``/openapi.json`` / ``/docs`` / ``/redoc``,
|
|
306
|
+
plain starlette ``Route``s with ``include_in_schema=False``) and any ``app.mount()``
|
|
307
|
+
sub-application are reachable surface: a complete permission audit must show them,
|
|
308
|
+
so they join ``kernel_routes`` instead of hiding outside the OpenAPI ground truth.
|
|
309
|
+
"""
|
|
310
|
+
hidden: list[dict[str, object]] = []
|
|
311
|
+
for route in app.routes:
|
|
312
|
+
if isinstance(route, APIRoute):
|
|
313
|
+
continue # schema-visible; reconciled via app.openapi()
|
|
314
|
+
if isinstance(route, Mount):
|
|
315
|
+
hidden.append(
|
|
316
|
+
{
|
|
317
|
+
"path": f"{route.path}/*",
|
|
318
|
+
"methods": ["*"],
|
|
319
|
+
"note": "mounted sub-application — its own routes are not "
|
|
320
|
+
"policy-guarded by any ModuleSpec",
|
|
321
|
+
}
|
|
322
|
+
)
|
|
323
|
+
elif isinstance(route, Route) and not route.include_in_schema:
|
|
324
|
+
hidden.append(
|
|
325
|
+
{
|
|
326
|
+
"path": route.path,
|
|
327
|
+
"methods": sorted(route.methods or ()),
|
|
328
|
+
"note": "framework route outside the OpenAPI schema (docs/schema)",
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
return hidden
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def build_access_graph_for_app(app: FastAPI) -> dict[str, object]:
|
|
335
|
+
"""The access graph for a fully composed app — the WHOLE guarded surface.
|
|
336
|
+
|
|
337
|
+
``create_app`` records the specs it mounted (client modules AND every discovered
|
|
338
|
+
capability router) and its control plane on ``app.state``; this reads them back so
|
|
339
|
+
the graph covers routes a hand-passed ``--module`` list would miss. It then
|
|
340
|
+
reconciles the graph against ``app.openapi()`` (the ground truth): every served
|
|
341
|
+
``/api/v1`` method must map to a module endpoint — any that does not is reported
|
|
342
|
+
under ``omitted_routes`` (fail-visible, so a mounted route can never hide) — and the
|
|
343
|
+
served routes outside ``/api/v1`` (the unauthenticated kernel health routes), the
|
|
344
|
+
schema-hidden framework routes (``/docs`` / ``/openapi.json`` / ``/redoc``), and any
|
|
345
|
+
``app.mount()`` sub-application are listed under ``kernel_routes``.
|
|
346
|
+
"""
|
|
347
|
+
specs = getattr(app.state, "terp_module_specs", None)
|
|
348
|
+
plane = getattr(app.state, "terp_control_plane", None)
|
|
349
|
+
if specs is None or plane is None:
|
|
350
|
+
raise ValueError(
|
|
351
|
+
"app was not composed by terp.core.create_app (no access-graph state on "
|
|
352
|
+
"app.state) — pass a terp app factory, e.g. --app app.main:build"
|
|
353
|
+
)
|
|
354
|
+
served = _served_routes(app)
|
|
355
|
+
kernel_routes: list[dict[str, object]] = [
|
|
356
|
+
{
|
|
357
|
+
"path": path,
|
|
358
|
+
"methods": sorted(methods),
|
|
359
|
+
"note": "unauthenticated kernel route (no module policy)",
|
|
360
|
+
}
|
|
361
|
+
for path, methods in sorted(served.items())
|
|
362
|
+
if not path.startswith(_API_PREFIX)
|
|
363
|
+
]
|
|
364
|
+
kernel_routes.extend(_schema_hidden_routes(app))
|
|
365
|
+
graph = build_access_graph(
|
|
366
|
+
plane,
|
|
367
|
+
specs,
|
|
368
|
+
kernel_routes=kernel_routes,
|
|
369
|
+
undeclared_subscribers=_undeclared_subscriber_alarms(specs),
|
|
370
|
+
)
|
|
371
|
+
covered: dict[str, set[str]] = {}
|
|
372
|
+
modules: list = graph["modules"] # type: ignore[assignment]
|
|
373
|
+
for module in modules:
|
|
374
|
+
for endpoint in module["endpoints"]:
|
|
375
|
+
covered.setdefault(endpoint["path"], set()).update(endpoint["methods"])
|
|
376
|
+
omitted: list[dict[str, object]] = []
|
|
377
|
+
for path, methods in sorted(served.items()):
|
|
378
|
+
if not path.startswith(_API_PREFIX):
|
|
379
|
+
continue
|
|
380
|
+
missing = sorted(methods - covered.get(path, set()))
|
|
381
|
+
if missing:
|
|
382
|
+
omitted.append({"path": path, "methods": missing})
|
|
383
|
+
graph["omitted_routes"] = omitted
|
|
384
|
+
return graph
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _render_access_text(graph: dict[str, object]) -> str:
|
|
388
|
+
lines = ["Access graph", "", "Roles"]
|
|
389
|
+
for role in graph["roles"]: # type: ignore[index, union-attr]
|
|
390
|
+
lines.append(f" {role['name']} ({role['rank']})")
|
|
391
|
+
lines.append("")
|
|
392
|
+
lines.append("Permissions")
|
|
393
|
+
permissions = graph["permissions"] # type: ignore[index]
|
|
394
|
+
if not permissions:
|
|
395
|
+
lines.append(" <none declared>")
|
|
396
|
+
for permission in permissions: # type: ignore[union-attr]
|
|
397
|
+
lines.append(f" {permission['name']} {permission['min_role']}+")
|
|
398
|
+
for module in graph["modules"]: # type: ignore[index, union-attr]
|
|
399
|
+
lines.append("")
|
|
400
|
+
prefix = module["prefix"] or "<no router>"
|
|
401
|
+
lines.append(f"Module {module['name']} ({prefix})")
|
|
402
|
+
policy = module["policy"]
|
|
403
|
+
if policy is None:
|
|
404
|
+
lines.append(" policy <missing> (boot refuses this module)")
|
|
405
|
+
elif policy["public"]:
|
|
406
|
+
lines.append(f" policy public ({policy['public_reason']})")
|
|
407
|
+
else:
|
|
408
|
+
lines.append(f" policy read={policy['read']} write={policy['write']}")
|
|
409
|
+
for endpoint in module["endpoints"]:
|
|
410
|
+
extra = (
|
|
411
|
+
f" +permissions: {', '.join(endpoint['extra_permissions'])}"
|
|
412
|
+
if endpoint["extra_permissions"]
|
|
413
|
+
else ""
|
|
414
|
+
)
|
|
415
|
+
lines.append(
|
|
416
|
+
f" {','.join(endpoint['methods']):8} {endpoint['path']:40} "
|
|
417
|
+
f"{endpoint['kind']:5} {endpoint['requirement']}{extra}"
|
|
418
|
+
)
|
|
419
|
+
for model in module["models"]:
|
|
420
|
+
read_scope = ", ".join(model["read_scope"]) or "none"
|
|
421
|
+
write_authority = ", ".join(model["write_authority"]) or "role tier only"
|
|
422
|
+
lines.append(
|
|
423
|
+
f" data {model['model']} ({model['table']}) "
|
|
424
|
+
f"read-scope: {read_scope} write-authority: {write_authority}"
|
|
425
|
+
)
|
|
426
|
+
for warning in module["warnings"]:
|
|
427
|
+
lines.append(f" ! {warning}")
|
|
428
|
+
lines.append("")
|
|
429
|
+
lines.append("Row-scope predicates (app-wide)")
|
|
430
|
+
scope_predicates = graph["scope_predicates"] # type: ignore[index]
|
|
431
|
+
if not scope_predicates:
|
|
432
|
+
lines.append(" <none registered>")
|
|
433
|
+
for name in scope_predicates: # type: ignore[union-attr]
|
|
434
|
+
lines.append(f" {name}")
|
|
435
|
+
lines.append("Object-authz predicates (app-wide)")
|
|
436
|
+
authz_predicates = graph["object_authz_predicates"] # type: ignore[index]
|
|
437
|
+
if not authz_predicates:
|
|
438
|
+
lines.append(" <none registered>")
|
|
439
|
+
for name in authz_predicates: # type: ignore[union-attr]
|
|
440
|
+
lines.append(f" {name}")
|
|
441
|
+
kernel_routes: list = graph.get("kernel_routes", []) # type: ignore[assignment]
|
|
442
|
+
if kernel_routes:
|
|
443
|
+
lines.append("")
|
|
444
|
+
lines.append("Kernel / unauthenticated routes")
|
|
445
|
+
for route in kernel_routes:
|
|
446
|
+
lines.append(f" {','.join(route['methods']):8} {route['path']}")
|
|
447
|
+
omitted: list = graph.get("omitted_routes", []) # type: ignore[assignment]
|
|
448
|
+
if omitted:
|
|
449
|
+
lines.append("")
|
|
450
|
+
lines.append("! OMITTED served routes — mounted but absent from the graph:")
|
|
451
|
+
for route in omitted:
|
|
452
|
+
lines.append(f" {','.join(route['methods']):8} {route['path']}")
|
|
453
|
+
undeclared: list = graph.get("undeclared_subscribers", []) # type: ignore[assignment]
|
|
454
|
+
if undeclared:
|
|
455
|
+
lines.append("")
|
|
456
|
+
lines.append("! UNDECLARED event subscribers — handlers no module declares:")
|
|
457
|
+
for entry in undeclared:
|
|
458
|
+
lines.append(f" {entry['event']}: {', '.join(entry['handlers'])}")
|
|
459
|
+
return "\n".join(lines)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def render_access_graph(graph: dict[str, object], fmt: str = "text") -> str:
|
|
463
|
+
"""Render a prebuilt access *graph* as ``text`` or ``json``."""
|
|
464
|
+
if fmt == "json":
|
|
465
|
+
return json.dumps(graph, indent=2)
|
|
466
|
+
return _render_access_text(graph)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def render_access(
|
|
470
|
+
plane: ControlPlane, specs: Sequence[ModuleSpec], fmt: str = "text"
|
|
471
|
+
) -> str:
|
|
472
|
+
"""Render the access graph for *plane* + *specs* as ``text`` or ``json``."""
|
|
473
|
+
return render_access_graph(build_access_graph(plane, specs), fmt)
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
__all__ = [
|
|
477
|
+
"PERMISSION_DEPENDENCY_ATTR",
|
|
478
|
+
"build_access_graph",
|
|
479
|
+
"build_access_graph_for_app",
|
|
480
|
+
"render_access",
|
|
481
|
+
"render_access_graph",
|
|
482
|
+
]
|
terp/cli/apidocs.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""``terp api-docs`` — generate the public-API contract from the live kernel.
|
|
2
|
+
|
|
3
|
+
Writes two artefacts from the *live* ``terp.core`` surface (and the ``terp.arch`` rule
|
|
4
|
+
registry), so they cannot drift from the package: ``platform-api.md`` (a grouped
|
|
5
|
+
reference) and ``terp_core.pyi`` (a flat stub of the semver-public names). An agent in
|
|
6
|
+
a consumer repo reads the generated reference instead of guessing the API.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import importlib
|
|
13
|
+
import inspect
|
|
14
|
+
import pathlib
|
|
15
|
+
|
|
16
|
+
_KIND_CLASS = "class"
|
|
17
|
+
_KIND_FUNCTION = "function"
|
|
18
|
+
_KIND_VALUE = "value"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _StubDefault:
|
|
22
|
+
"""A placeholder whose ``repr`` is ``...`` so a stub renders the ``.pyi`` default idiom."""
|
|
23
|
+
|
|
24
|
+
def __repr__(self) -> str:
|
|
25
|
+
return "..."
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_STUB_DEFAULT = _StubDefault()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _kind(value: object) -> str:
|
|
32
|
+
if inspect.isclass(value):
|
|
33
|
+
return _KIND_CLASS
|
|
34
|
+
if inspect.isfunction(value) or inspect.isbuiltin(value):
|
|
35
|
+
return _KIND_FUNCTION
|
|
36
|
+
return _KIND_VALUE
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _summary(value: object) -> str:
|
|
40
|
+
"""The first line of the object's docstring, or an empty string."""
|
|
41
|
+
doc = inspect.getdoc(value)
|
|
42
|
+
return doc.splitlines()[0].strip() if doc else ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _signature(value: object) -> str:
|
|
46
|
+
try:
|
|
47
|
+
return str(inspect.signature(value)) # type: ignore[arg-type]
|
|
48
|
+
except (TypeError, ValueError): # pragma: no cover - defensive; kernel sigs resolve
|
|
49
|
+
return "(...)"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _stub_signature(value: object) -> str:
|
|
53
|
+
"""A stub-safe signature: real parameter names + annotations, defaults shown as ``...``.
|
|
54
|
+
|
|
55
|
+
``inspect.signature`` renders a default by ``repr``, so a non-literal default (a
|
|
56
|
+
function or object — e.g. ``principal_provider=get_principal``) would emit unparseable
|
|
57
|
+
text (``=<function ...>``). Replacing every default with the ``...`` placeholder keeps
|
|
58
|
+
the stub the valid, idiomatic ``.pyi`` form; a signature that cannot be introspected
|
|
59
|
+
falls back to ``(...)``.
|
|
60
|
+
"""
|
|
61
|
+
try:
|
|
62
|
+
sig = inspect.signature(value) # type: ignore[arg-type]
|
|
63
|
+
except (TypeError, ValueError): # pragma: no cover - defensive; kernel sigs resolve
|
|
64
|
+
return "(...)"
|
|
65
|
+
params = [
|
|
66
|
+
param.replace(default=_STUB_DEFAULT)
|
|
67
|
+
if param.default is not inspect.Parameter.empty
|
|
68
|
+
else param
|
|
69
|
+
for param in sig.parameters.values()
|
|
70
|
+
]
|
|
71
|
+
rendered = str(sig.replace(parameters=params))
|
|
72
|
+
try:
|
|
73
|
+
ast.parse(f"def _stub{rendered}: ...")
|
|
74
|
+
except SyntaxError: # pragma: no cover - defensive; annotations are stringized refs
|
|
75
|
+
return "(...)"
|
|
76
|
+
return rendered
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _public_surface() -> list[tuple[str, str, object]]:
|
|
80
|
+
"""``(name, kind, value)`` for every name in ``terp.core.__all__``, sorted."""
|
|
81
|
+
core = importlib.import_module("terp.core")
|
|
82
|
+
rows: list[tuple[str, str, object]] = []
|
|
83
|
+
for name in sorted(core.__all__):
|
|
84
|
+
value = getattr(core, name)
|
|
85
|
+
rows.append((name, _kind(value), value))
|
|
86
|
+
return rows
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _markdown(rows: list[tuple[str, str, object]]) -> str:
|
|
90
|
+
lines = [
|
|
91
|
+
"# Terp platform API (generated)",
|
|
92
|
+
"",
|
|
93
|
+
"The semver-public surface of `terp.core`, generated by `terp api-docs` — do not",
|
|
94
|
+
"edit by hand. Import only these names; everything else is internal.",
|
|
95
|
+
"",
|
|
96
|
+
]
|
|
97
|
+
for kind, heading in ((_KIND_CLASS, "Classes"), (_KIND_FUNCTION, "Functions"), (_KIND_VALUE, "Values")):
|
|
98
|
+
members = [(name, value) for name, member_kind, value in rows if member_kind == kind]
|
|
99
|
+
lines.append(f"## {heading}")
|
|
100
|
+
lines.append("")
|
|
101
|
+
for name, value in members:
|
|
102
|
+
sig = _signature(value) if kind == _KIND_FUNCTION else ""
|
|
103
|
+
summary = _summary(value)
|
|
104
|
+
lines.append(f"- `{name}{sig}`" + (f" — {summary}" if summary else ""))
|
|
105
|
+
lines.append("")
|
|
106
|
+
return "\n".join(lines)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _stub(rows: list[tuple[str, str, object]]) -> str:
|
|
110
|
+
lines = ["# Generated by `terp api-docs` — the terp.core public surface. Do not edit.", "from typing import Any", ""]
|
|
111
|
+
for name, kind, value in rows:
|
|
112
|
+
if kind == _KIND_CLASS:
|
|
113
|
+
lines.append(f"class {name}: ...")
|
|
114
|
+
elif kind == _KIND_FUNCTION:
|
|
115
|
+
# Emit the *real* signature (parameter names + annotations, defaults as `...`)
|
|
116
|
+
# so the stub is a usable contract, not a content-free `(*args, **kwargs) -> Any`.
|
|
117
|
+
lines.append(f"def {name}{_stub_signature(value)}: ...")
|
|
118
|
+
else:
|
|
119
|
+
lines.append(f"{name}: Any")
|
|
120
|
+
return "\n".join(lines) + "\n"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def api_docs(out_dir: str | pathlib.Path = "docs") -> list[pathlib.Path]:
|
|
124
|
+
"""Write ``platform-api.md`` + ``terp_core.pyi`` into *out_dir*; return their paths."""
|
|
125
|
+
rows = _public_surface()
|
|
126
|
+
destination = pathlib.Path(out_dir)
|
|
127
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
markdown = destination / "platform-api.md"
|
|
129
|
+
stub = destination / "terp_core.pyi"
|
|
130
|
+
markdown.write_text(_markdown(rows), encoding="utf-8")
|
|
131
|
+
stub.write_text(_stub(rows), encoding="utf-8")
|
|
132
|
+
return [markdown, stub]
|