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/_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]