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/scaffold.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""``terp new module`` — scaffold a canonical secure-by-default module (Tier-C).
|
|
2
|
+
|
|
3
|
+
Emits the five fixed slots (``models`` / ``schemas`` / ``service`` / ``router`` /
|
|
4
|
+
``module``) plus the package ``__init__`` into ``<app>/modules/<name>/``. The output
|
|
5
|
+
is deliberately the *canonical* shape: it passes every ``terp.arch`` rule out of the
|
|
6
|
+
box, so the only remaining step before a green gate is the model's first migration
|
|
7
|
+
(``terp migrate make <name>``) — the 10-minute path of design §13.
|
|
8
|
+
|
|
9
|
+
The generated code is yours to edit (Level-1 sugar, never a runtime black box):
|
|
10
|
+
inherit ``BaseTable``, declare DTOs, set ``model`` on a ``BaseService``, keep the
|
|
11
|
+
router thin. There is no magic to remove.
|
|
12
|
+
|
|
13
|
+
``--profile`` picks a :mod:`permission profile <terp.cli.profiles>` — a preset that
|
|
14
|
+
only decides which existing primitives the slots compose (``Policy``, model traits,
|
|
15
|
+
service base); the output stays canonical and gate-checked either way.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import keyword
|
|
21
|
+
import pathlib
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
|
|
24
|
+
from terp.cli.profiles import DEFAULT_PROFILE, ModuleProfile, get_profile
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _singular(name: str) -> str:
|
|
28
|
+
"""``invoices`` -> ``invoice``; ``billing`` -> ``billing`` (drop a trailing ``s``)."""
|
|
29
|
+
return name[:-1] if name.endswith("s") else name
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _model_name(name: str) -> str:
|
|
33
|
+
"""``invoices`` -> ``Invoice``; ``billing`` -> ``Billing`` (PascalCase, singular)."""
|
|
34
|
+
singular = _singular(name)
|
|
35
|
+
return singular[:1].upper() + singular[1:]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pascal(name: str) -> str:
|
|
39
|
+
"""``invoices`` -> ``Invoices`` (PascalCase, plurality unchanged) for view/nav names."""
|
|
40
|
+
return name[:1].upper() + name[1:]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validate_name(name: str) -> str:
|
|
44
|
+
"""A module name must be a lowercase, importable identifier (no dots / spaces)."""
|
|
45
|
+
if not name.isidentifier() or keyword.iskeyword(name):
|
|
46
|
+
raise SystemExit(
|
|
47
|
+
f"invalid module name {name!r}: use a lowercase Python identifier "
|
|
48
|
+
"(e.g. 'invoices')"
|
|
49
|
+
)
|
|
50
|
+
return name
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _models_py(model: str, name: str, profile: ModuleProfile) -> str:
|
|
54
|
+
core_imports = ", ".join(("BaseTable", *sorted(profile.core_model_mixins)))
|
|
55
|
+
extra_imports = "".join(f"{line}\n" for line in profile.model_import_lines)
|
|
56
|
+
bases = ", ".join(("BaseTable", *profile.model_mixins))
|
|
57
|
+
return f'''\
|
|
58
|
+
"""``{name}`` table model — composes the kernel ``BaseTable`` (UUID + timestamps + OCC)."""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
from sqlmodel import Field
|
|
63
|
+
|
|
64
|
+
{extra_imports}from terp.core import {core_imports}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class {model}({bases}, table=True):
|
|
68
|
+
"""``id`` / ``created_at`` / ``updated_at`` / ``version`` are inherited — never redeclared."""
|
|
69
|
+
|
|
70
|
+
__tablename__ = "{_singular(name)}"
|
|
71
|
+
|
|
72
|
+
name: str = Field(max_length=200, index=True)
|
|
73
|
+
'''
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _schemas_py(model: str) -> str:
|
|
77
|
+
return f'''\
|
|
78
|
+
"""``{model}`` DTOs — compose the kernel schema bases; cap every input string."""
|
|
79
|
+
|
|
80
|
+
from __future__ import annotations
|
|
81
|
+
|
|
82
|
+
import datetime
|
|
83
|
+
import uuid
|
|
84
|
+
|
|
85
|
+
from sqlmodel import Field
|
|
86
|
+
|
|
87
|
+
from terp.core import BaseSchema, BaseUpdateSchema
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class {model}Create(BaseSchema):
|
|
91
|
+
name: str = Field(max_length=200)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class {model}Update(BaseUpdateSchema):
|
|
95
|
+
name: str | None = Field(default=None, max_length=200)
|
|
96
|
+
# `version: int` is inherited and required (optimistic concurrency).
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class {model}Read(BaseSchema):
|
|
100
|
+
id: uuid.UUID
|
|
101
|
+
name: str
|
|
102
|
+
version: int
|
|
103
|
+
created_at: datetime.datetime
|
|
104
|
+
updated_at: datetime.datetime
|
|
105
|
+
'''
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _service_py(model: str, name: str, package: str, profile: ModuleProfile) -> str:
|
|
109
|
+
base = profile.service_base
|
|
110
|
+
return f'''\
|
|
111
|
+
"""``{name}`` service — CRUD is inherited from ``{base}`` (audited, OCC, scoped)."""
|
|
112
|
+
|
|
113
|
+
from __future__ import annotations
|
|
114
|
+
|
|
115
|
+
{profile.service_import_line}
|
|
116
|
+
|
|
117
|
+
from {package}.modules.{name}.models import {model}
|
|
118
|
+
from {package}.modules.{name}.schemas import {model}Create, {model}Update
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class {model}Service({base}[{model}, {model}Create, {model}Update]):
|
|
122
|
+
model = {model}
|
|
123
|
+
'''
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _router_py(model: str, name: str, package: str) -> str:
|
|
127
|
+
return f'''\
|
|
128
|
+
"""``{name}`` router — thin CRUD over :class:`{model}Service` using kernel seams only."""
|
|
129
|
+
|
|
130
|
+
from __future__ import annotations
|
|
131
|
+
|
|
132
|
+
import uuid
|
|
133
|
+
|
|
134
|
+
from fastapi import APIRouter
|
|
135
|
+
|
|
136
|
+
from terp.core import Page, PaginationDep, SessionDep
|
|
137
|
+
|
|
138
|
+
from {package}.modules.{name}.schemas import {model}Create, {model}Read, {model}Update
|
|
139
|
+
from {package}.modules.{name}.service import {model}Service
|
|
140
|
+
|
|
141
|
+
router = APIRouter(tags=["{name}"])
|
|
142
|
+
_service = {model}Service()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.get("/", response_model=Page[{model}Read])
|
|
146
|
+
def list_{name}(session: SessionDep, pagination: PaginationDep) -> Page[{model}Read]:
|
|
147
|
+
rows, total = _service.list(session, skip=pagination.skip, limit=pagination.limit)
|
|
148
|
+
return Page[{model}Read].of(
|
|
149
|
+
[{model}Read.model_validate(row) for row in rows], total, pagination
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@router.post("/", response_model={model}Read, status_code=201)
|
|
154
|
+
def create_{model.lower()}(payload: {model}Create, session: SessionDep) -> {model}Read:
|
|
155
|
+
return {model}Read.model_validate(_service.create(session, payload))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@router.get("/{{item_id}}", response_model={model}Read)
|
|
159
|
+
def get_{model.lower()}(item_id: uuid.UUID, session: SessionDep) -> {model}Read:
|
|
160
|
+
return {model}Read.model_validate(_service.get(session, item_id))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@router.patch("/{{item_id}}", response_model={model}Read)
|
|
164
|
+
def update_{model.lower()}(
|
|
165
|
+
item_id: uuid.UUID, payload: {model}Update, session: SessionDep
|
|
166
|
+
) -> {model}Read:
|
|
167
|
+
return {model}Read.model_validate(_service.update(session, item_id, payload))
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@router.delete("/{{item_id}}", status_code=204)
|
|
171
|
+
def delete_{model.lower()}(item_id: uuid.UUID, session: SessionDep) -> None:
|
|
172
|
+
_service.delete(session, item_id)
|
|
173
|
+
'''
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _module_py(name: str, package: str, profile: ModuleProfile) -> str:
|
|
177
|
+
model = _model_name(name)
|
|
178
|
+
core_imports = ", ".join(sorted(profile.policy_imports))
|
|
179
|
+
return f'''\
|
|
180
|
+
"""``{name}`` manifest — the entire public surface; profile ``{profile.name}``."""
|
|
181
|
+
|
|
182
|
+
from __future__ import annotations
|
|
183
|
+
|
|
184
|
+
from terp.core import {core_imports}
|
|
185
|
+
|
|
186
|
+
from {package}.modules.{name}.router import router
|
|
187
|
+
from {package}.modules.{name}.service import {model}Service
|
|
188
|
+
|
|
189
|
+
module = ModuleSpec(
|
|
190
|
+
name="{name}",
|
|
191
|
+
router=router,
|
|
192
|
+
policy={profile.policy_expr},
|
|
193
|
+
services=({model}Service,),
|
|
194
|
+
)
|
|
195
|
+
'''
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _files(name: str, package: str, profile: ModuleProfile) -> dict[str, str]:
|
|
199
|
+
model = _model_name(name)
|
|
200
|
+
return {
|
|
201
|
+
"__init__.py": "",
|
|
202
|
+
"models.py": _models_py(model, name, profile),
|
|
203
|
+
"schemas.py": _schemas_py(model),
|
|
204
|
+
"service.py": _service_py(model, name, package, profile),
|
|
205
|
+
"router.py": _router_py(model, name, package),
|
|
206
|
+
"module.py": _module_py(name, package, profile),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_MODULE_TSX = """\
|
|
211
|
+
import { defineModuleManifest } from "@terp/contract";
|
|
212
|
+
|
|
213
|
+
import { __PASCAL__List } from "./__PASCAL__List";
|
|
214
|
+
|
|
215
|
+
// Dropped in and auto-discovered by renderTerpApp's import.meta.glob — no registration.
|
|
216
|
+
export const manifest = defineModuleManifest({
|
|
217
|
+
name: "__NAME__",
|
|
218
|
+
routes: [{ path: "/__NAME__", view: "__PASCAL__List" }],
|
|
219
|
+
nav: [{ label: "__PASCAL__", to: "/__NAME__" }],
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
export const views = { __PASCAL__List };
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
_VIEW_TSX = """\
|
|
226
|
+
// The `__NAME__` module view — a starting point you own (real React, no hidden DSL). It composes
|
|
227
|
+
// the shared <ResourceList> so every module lists + creates the same way, with the write-gate
|
|
228
|
+
// applied for you. Wire it to YOUR endpoint once you've generated the typed client:
|
|
229
|
+
//
|
|
230
|
+
// uv run terp openapi && npm --prefix frontend run generate // -> src/api/schema.d.ts
|
|
231
|
+
//
|
|
232
|
+
// import { useResource, useTerpClient } from "@terp/react-core";
|
|
233
|
+
// import type { paths, components } from "../../api/schema";
|
|
234
|
+
// type __PASCAL__ = components["schemas"]["__PASCAL__Read"];
|
|
235
|
+
// const client = useTerpClient<paths>();
|
|
236
|
+
// const __NAME__ = useResource<__PASCAL__, string>({
|
|
237
|
+
// list: async () => (await client.GET("/api/v1/__NAME__/", {})).data?.items ?? [],
|
|
238
|
+
// create: async (name) => { await client.POST("/api/v1/__NAME__/", { body: { name } }); },
|
|
239
|
+
// });
|
|
240
|
+
// // ...then pass createPlaceholder="New __NAME__" to <ResourceList> so writers can add rows.
|
|
241
|
+
//
|
|
242
|
+
import { OverviewPage, ResourceList, useResource } from "@terp/react-core";
|
|
243
|
+
|
|
244
|
+
export function __PASCAL__List() {
|
|
245
|
+
// Placeholder until you wire the typed client above: an empty, read-only list (compiles pre-codegen).
|
|
246
|
+
const __NAME__ = useResource<{ id: string; name: string }, string>({ list: async () => [] });
|
|
247
|
+
return (
|
|
248
|
+
<OverviewPage title="__PASCAL__">
|
|
249
|
+
<ResourceList
|
|
250
|
+
resource={__NAME__}
|
|
251
|
+
renderItem={(item) => <strong>{item.name}</strong>}
|
|
252
|
+
/>
|
|
253
|
+
</OverviewPage>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
"""
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _frontend_files(name: str) -> dict[str, str]:
|
|
260
|
+
"""The frontend slot: a self-describing ``module.tsx`` + a starter list view."""
|
|
261
|
+
pascal = _pascal(name)
|
|
262
|
+
return {
|
|
263
|
+
"module.tsx": _MODULE_TSX.replace("__PASCAL__", pascal).replace("__NAME__", name),
|
|
264
|
+
f"{pascal}List.tsx": _VIEW_TSX.replace("__PASCAL__", pascal).replace("__NAME__", name),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def new_module(
|
|
269
|
+
name: str,
|
|
270
|
+
*,
|
|
271
|
+
root: str | pathlib.Path = ".",
|
|
272
|
+
package: str = "app",
|
|
273
|
+
frontend: bool = True,
|
|
274
|
+
profile: str = DEFAULT_PROFILE,
|
|
275
|
+
) -> list[pathlib.Path]:
|
|
276
|
+
"""Scaffold the canonical ``<package>/modules/<name>/`` module under *root*.
|
|
277
|
+
|
|
278
|
+
When *frontend* is true and a ``frontend/src/modules`` app exists under *root*, the
|
|
279
|
+
matching frontend slot (``module.tsx`` + a starter ``<Name>List.tsx`` view) is emitted
|
|
280
|
+
too, so the module is full-stack and auto-discovered by ``renderTerpApp`` — no central
|
|
281
|
+
registration. A backend-only repo (no frontend app) silently gets just the backend.
|
|
282
|
+
|
|
283
|
+
Returns the created file paths. Raises :class:`SystemExit` for an invalid name or
|
|
284
|
+
an existing destination, so a partial overwrite never happens.
|
|
285
|
+
"""
|
|
286
|
+
name = _validate_name(name)
|
|
287
|
+
module_profile = get_profile(profile)
|
|
288
|
+
root_path = pathlib.Path(root)
|
|
289
|
+
destination = root_path / package / "modules" / name
|
|
290
|
+
if destination.exists():
|
|
291
|
+
raise SystemExit(f"refusing to overwrite existing module directory: {destination}")
|
|
292
|
+
|
|
293
|
+
frontend_modules = root_path / "frontend" / "src" / "modules"
|
|
294
|
+
emit_frontend = frontend and frontend_modules.is_dir()
|
|
295
|
+
frontend_destination = frontend_modules / name
|
|
296
|
+
if emit_frontend and frontend_destination.exists():
|
|
297
|
+
raise SystemExit(
|
|
298
|
+
f"refusing to overwrite existing frontend module directory: {frontend_destination}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
destination.mkdir(parents=True)
|
|
302
|
+
created: list[pathlib.Path] = []
|
|
303
|
+
for filename, content in _files(name, package, module_profile).items():
|
|
304
|
+
path = destination / filename
|
|
305
|
+
path.write_text(content, encoding="utf-8")
|
|
306
|
+
created.append(path)
|
|
307
|
+
|
|
308
|
+
if emit_frontend:
|
|
309
|
+
frontend_destination.mkdir(parents=True)
|
|
310
|
+
for filename, content in _frontend_files(name).items():
|
|
311
|
+
path = frontend_destination / filename
|
|
312
|
+
path.write_text(content, encoding="utf-8")
|
|
313
|
+
created.append(path)
|
|
314
|
+
|
|
315
|
+
return created
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def new_module_message(
|
|
319
|
+
name: str, paths: Sequence[pathlib.Path], *, profile: str = DEFAULT_PROFILE
|
|
320
|
+
) -> str:
|
|
321
|
+
"""The human/agent-facing next-steps note after scaffolding *name*."""
|
|
322
|
+
module_profile = get_profile(profile)
|
|
323
|
+
listing = "\n".join(f" {path}" for path in paths)
|
|
324
|
+
has_frontend = any(path.suffix == ".tsx" for path in paths)
|
|
325
|
+
steps = [
|
|
326
|
+
f" app/main.py # mount it: from app.modules.{name}.module "
|
|
327
|
+
f"import module as {name}_module; add {name}_module to the modules list",
|
|
328
|
+
f" uv run terp migrate make {name} # first migration (before a green gate)",
|
|
329
|
+
]
|
|
330
|
+
if has_frontend:
|
|
331
|
+
steps.append(
|
|
332
|
+
f" uv run terp openapi # regenerate the contract so the client sees /api/v1/{name}/"
|
|
333
|
+
)
|
|
334
|
+
steps.append(
|
|
335
|
+
" npm --prefix frontend run generate # openapi.json -> frontend/src/api/schema.d.ts (typed client)"
|
|
336
|
+
)
|
|
337
|
+
steps.append(" uv run terp check # run the architecture gate locally")
|
|
338
|
+
notes = "".join(f"\nNote: {note}" for note in module_profile.notes)
|
|
339
|
+
return (
|
|
340
|
+
f"Scaffolded module {name!r} ({len(paths)} files, profile {module_profile.name!r}):\n"
|
|
341
|
+
f"{listing}\n\n"
|
|
342
|
+
"Next:\n" + "\n".join(steps) + notes
|
|
343
|
+
)
|
terp/cli/schema.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""``terp inspect schema`` — the Schema Graph: every table, owned and alarmed.
|
|
2
|
+
|
|
3
|
+
The database schema has one sanctioned source of truth: the shared SQLModel
|
|
4
|
+
metadata, populated by importing every declared **migration tree**'s models
|
|
5
|
+
module (exactly how ``terp migrate`` discovers models). This module projects
|
|
6
|
+
that metadata into one structured report — JSON-first so
|
|
7
|
+
external tooling (Terp Studio) can visualize the data model without importing
|
|
8
|
+
``terp.*`` — and reconciles it fail-visibly, mirroring the access graph:
|
|
9
|
+
|
|
10
|
+
* **``tables``** — every mapped table, attributed to its owning migration tree
|
|
11
|
+
(``module`` + ``kind``) with its column/foreign-key detail and the enforced
|
|
12
|
+
kernel traits (``BaseTable`` lineage, soft-delete / owned / tenant-scoped /
|
|
13
|
+
actor-stamped).
|
|
14
|
+
* **``unowned_tables``** — mapped tables whose model module NO migration tree
|
|
15
|
+
owns: ``terp migrate`` will never manage their schema (drift risk). Alarmed,
|
|
16
|
+
never silently mislabeled.
|
|
17
|
+
* **``non_canonical_models``** — mapped classes that do not inherit
|
|
18
|
+
``BaseTable``: they bypass the kernel's id/timestamps/OCC contract (the
|
|
19
|
+
``table_models_use_base_table`` gate rule is the build-time half; this is the
|
|
20
|
+
inspection's fail-visible view of the same drift).
|
|
21
|
+
* **``unmapped_tables``** — raw ``Table`` objects on the metadata with no
|
|
22
|
+
mapped class (hand-rolled DDL; the ``no_manual_table_schema`` rule's
|
|
23
|
+
build-time territory) — reported, never dropped.
|
|
24
|
+
* **``unimported_models``** — ``table=True`` models found by a SOURCE SCAN of
|
|
25
|
+
the app tree that never landed on the metadata: a model defined outside the
|
|
26
|
+
sanctioned ``models.py`` import path would otherwise be invisible to every
|
|
27
|
+
runtime view. The scan is the reconciliation ground truth the metadata is
|
|
28
|
+
held against, exactly like ``app.openapi()`` for routes.
|
|
29
|
+
|
|
30
|
+
This is a **view, never a second source of truth** (ADR 0011).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import ast
|
|
36
|
+
import importlib
|
|
37
|
+
import json
|
|
38
|
+
import pathlib
|
|
39
|
+
from collections.abc import Iterable, Sequence
|
|
40
|
+
|
|
41
|
+
from sqlmodel import SQLModel
|
|
42
|
+
|
|
43
|
+
from terp.core import ActorStampedMixin, BaseTable, OwnedMixin, SoftDeleteMixin
|
|
44
|
+
from terp.core.migrations import MigrationTree, resolve_all_migration_trees
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def import_declared_models(
|
|
48
|
+
app_root: str | pathlib.Path | None = None, *, package: str = "app"
|
|
49
|
+
) -> list[MigrationTree]:
|
|
50
|
+
"""Import every declared migration tree's models module (the sanctioned loader).
|
|
51
|
+
|
|
52
|
+
Mirrors ``terp migrate``'s model discovery: each tree's ``<import_path>.models``
|
|
53
|
+
is imported into the shared SQLModel metadata. A tree without a models module is
|
|
54
|
+
a route-only package — skipped, exactly like the migration runtime does.
|
|
55
|
+
"""
|
|
56
|
+
trees = resolve_all_migration_trees(app_root, package=package)
|
|
57
|
+
for tree in trees:
|
|
58
|
+
module = tree.models_module
|
|
59
|
+
try:
|
|
60
|
+
importlib.import_module(module)
|
|
61
|
+
except ModuleNotFoundError as exc:
|
|
62
|
+
missing = exc.name or ""
|
|
63
|
+
if missing == module or module.startswith(missing + "."):
|
|
64
|
+
continue # route-only package: not a table owner
|
|
65
|
+
raise
|
|
66
|
+
return trees
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _owning_tree(model_module: str, trees: Sequence[MigrationTree]) -> MigrationTree | None:
|
|
70
|
+
"""The tree whose ``import_path`` is the longest ownership prefix of *model_module*."""
|
|
71
|
+
best: MigrationTree | None = None
|
|
72
|
+
best_len = -1
|
|
73
|
+
for tree in trees:
|
|
74
|
+
path = tree.import_path
|
|
75
|
+
owns = model_module == path or model_module.startswith(path + ".")
|
|
76
|
+
if owns and len(path) > best_len:
|
|
77
|
+
best, best_len = tree, len(path)
|
|
78
|
+
return best
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _mro_names(model: type) -> set[str]:
|
|
82
|
+
return {klass.__name__ for klass in model.__mro__}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _column_json(column: object) -> dict[str, object]:
|
|
86
|
+
try:
|
|
87
|
+
type_name = str(column.type) # type: ignore[attr-defined]
|
|
88
|
+
except Exception: # pragma: no cover - exotic custom types only
|
|
89
|
+
type_name = type(column.type).__name__ # type: ignore[attr-defined]
|
|
90
|
+
return {
|
|
91
|
+
"name": column.name, # type: ignore[attr-defined]
|
|
92
|
+
"type": type_name,
|
|
93
|
+
"nullable": bool(column.nullable), # type: ignore[attr-defined]
|
|
94
|
+
"primary_key": bool(column.primary_key), # type: ignore[attr-defined]
|
|
95
|
+
"unique": bool(column.unique), # type: ignore[attr-defined]
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _table_json(table: object) -> dict[str, object]:
|
|
100
|
+
return {
|
|
101
|
+
"name": table.name, # type: ignore[attr-defined]
|
|
102
|
+
"schema": table.schema, # type: ignore[attr-defined]
|
|
103
|
+
"columns": [_column_json(column) for column in table.columns], # type: ignore[attr-defined]
|
|
104
|
+
"foreign_keys": sorted(
|
|
105
|
+
(
|
|
106
|
+
{
|
|
107
|
+
"column": fk.parent.name,
|
|
108
|
+
"references_table": fk.column.table.name,
|
|
109
|
+
"references_column": fk.column.name,
|
|
110
|
+
}
|
|
111
|
+
for fk in table.foreign_keys # type: ignore[attr-defined]
|
|
112
|
+
),
|
|
113
|
+
key=lambda item: (item["column"], item["references_table"]),
|
|
114
|
+
),
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _traits_json(model: type) -> dict[str, bool]:
|
|
119
|
+
return {
|
|
120
|
+
"base_table": issubclass(model, BaseTable),
|
|
121
|
+
"soft_delete": issubclass(model, SoftDeleteMixin),
|
|
122
|
+
"owned": issubclass(model, OwnedMixin),
|
|
123
|
+
"tenant_scoped": "TenantScopedMixin" in _mro_names(model),
|
|
124
|
+
"actor_stamped": issubclass(model, ActorStampedMixin),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def scan_declared_table_models(
|
|
129
|
+
app_root: str | pathlib.Path, *, package: str = "app"
|
|
130
|
+
) -> dict[tuple[str, str], tuple[str, int]]:
|
|
131
|
+
"""Source-scan ground truth: every ``table=True`` class in the app tree.
|
|
132
|
+
|
|
133
|
+
Maps ``(class name, dotted module derived from the file path)`` ->
|
|
134
|
+
``(relative file, line)``. AST-only (nothing is imported), so a model defined
|
|
135
|
+
anywhere in the tree is found even when no sanctioned import path reaches it —
|
|
136
|
+
the reconciliation that makes "never skip a model" checkable. Keying by the
|
|
137
|
+
(class, module) pair means a stray app model cannot hide behind an
|
|
138
|
+
already-mapped capability class of the same name.
|
|
139
|
+
"""
|
|
140
|
+
root = pathlib.Path(app_root)
|
|
141
|
+
found: dict[tuple[str, str], tuple[str, int]] = {}
|
|
142
|
+
skip = {"__pycache__", "tests", ".venv", "node_modules", "migrations"}
|
|
143
|
+
for path in sorted(root.rglob("*.py")):
|
|
144
|
+
if any(part in skip for part in path.parts):
|
|
145
|
+
continue
|
|
146
|
+
try:
|
|
147
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
148
|
+
except SyntaxError: # pragma: no cover - unparseable files are the gate's job
|
|
149
|
+
continue
|
|
150
|
+
relative = path.relative_to(root).as_posix()
|
|
151
|
+
# app/modules/x/models.py -> app.modules.x.models (app_root sits on sys.path,
|
|
152
|
+
# so this is the module name the class would carry once imported).
|
|
153
|
+
dotted = relative[: -len(".py")].replace("/", ".")
|
|
154
|
+
if dotted.endswith(".__init__"): # pragma: no cover - models never live there
|
|
155
|
+
dotted = dotted[: -len(".__init__")]
|
|
156
|
+
for node in ast.walk(tree):
|
|
157
|
+
if not isinstance(node, ast.ClassDef):
|
|
158
|
+
continue
|
|
159
|
+
table_kw = any(
|
|
160
|
+
keyword.arg == "table"
|
|
161
|
+
and isinstance(keyword.value, ast.Constant)
|
|
162
|
+
and keyword.value.value is True
|
|
163
|
+
for keyword in node.keywords
|
|
164
|
+
)
|
|
165
|
+
if table_kw:
|
|
166
|
+
found[(node.name, dotted)] = (relative, node.lineno)
|
|
167
|
+
return found
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def build_schema_graph(
|
|
171
|
+
trees: Sequence[MigrationTree],
|
|
172
|
+
*,
|
|
173
|
+
mappers: Iterable[object] | None = None,
|
|
174
|
+
metadata_tables: Iterable[object] | None = None,
|
|
175
|
+
source_models: dict[tuple[str, str], tuple[str, int]] | None = None,
|
|
176
|
+
) -> dict[str, object]:
|
|
177
|
+
"""The Schema Graph as plain data: tables -> ownership -> traits -> alarms.
|
|
178
|
+
|
|
179
|
+
``mappers`` / ``metadata_tables`` default to the live SQLModel registry and
|
|
180
|
+
metadata (the runtime truth); they are injectable so alarm shapes are testable
|
|
181
|
+
without polluting the process-global registry. ``source_models`` is the AST
|
|
182
|
+
ground truth from :func:`scan_declared_table_models`; any scanned model whose
|
|
183
|
+
table never reached the metadata is alarmed under ``unimported_models``.
|
|
184
|
+
"""
|
|
185
|
+
live_mappers = (
|
|
186
|
+
mappers if mappers is not None else list(SQLModel._sa_registry.mappers)
|
|
187
|
+
)
|
|
188
|
+
live_tables = (
|
|
189
|
+
metadata_tables
|
|
190
|
+
if metadata_tables is not None
|
|
191
|
+
else sorted(SQLModel.metadata.tables.values(), key=lambda table: table.name)
|
|
192
|
+
)
|
|
193
|
+
tables: list[dict[str, object]] = []
|
|
194
|
+
unowned: list[dict[str, object]] = []
|
|
195
|
+
non_canonical: list[dict[str, object]] = []
|
|
196
|
+
mapped_table_names: set[str] = set()
|
|
197
|
+
mapped_models: set[tuple[str, str]] = set()
|
|
198
|
+
for mapper in sorted(
|
|
199
|
+
live_mappers, key=lambda item: item.local_table.name if item.local_table is not None else ""
|
|
200
|
+
):
|
|
201
|
+
table = mapper.local_table # type: ignore[attr-defined]
|
|
202
|
+
model = mapper.class_ # type: ignore[attr-defined]
|
|
203
|
+
if table is None: # pragma: no cover - non-table mappers only
|
|
204
|
+
continue
|
|
205
|
+
mapped_table_names.add(table.name)
|
|
206
|
+
mapped_models.add((model.__name__, model.__module__))
|
|
207
|
+
tree = _owning_tree(model.__module__, trees)
|
|
208
|
+
entry = {
|
|
209
|
+
**_table_json(table),
|
|
210
|
+
"model": model.__name__,
|
|
211
|
+
"module": tree.label if tree is not None else None,
|
|
212
|
+
"kind": (
|
|
213
|
+
("capability" if tree.import_path.startswith("terp.") else "app")
|
|
214
|
+
if tree is not None
|
|
215
|
+
else None
|
|
216
|
+
),
|
|
217
|
+
"traits": _traits_json(model),
|
|
218
|
+
}
|
|
219
|
+
tables.append(entry)
|
|
220
|
+
if tree is None:
|
|
221
|
+
unowned.append(
|
|
222
|
+
{
|
|
223
|
+
"table": table.name,
|
|
224
|
+
"model": model.__name__,
|
|
225
|
+
"model_module": model.__module__,
|
|
226
|
+
"detail": "no migration tree owns this model's module -- "
|
|
227
|
+
"terp migrate will never manage its schema",
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
# The BaseTable contract is alarmed for the APP's own models (and unowned
|
|
231
|
+
# strays). A capability's non-BaseTable primitive (e.g. the append-only
|
|
232
|
+
# audit event) is the framework's governed territory -- its escape-hatch
|
|
233
|
+
# budget covers it, and the trait stays visible on the table entry.
|
|
234
|
+
if not issubclass(model, BaseTable) and (
|
|
235
|
+
tree is None or not tree.import_path.startswith("terp.")
|
|
236
|
+
):
|
|
237
|
+
non_canonical.append(
|
|
238
|
+
{
|
|
239
|
+
"table": table.name,
|
|
240
|
+
"model": model.__name__,
|
|
241
|
+
"detail": "does not inherit terp.core.BaseTable -- it bypasses the "
|
|
242
|
+
"kernel id/timestamps/optimistic-concurrency contract",
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
unmapped = [
|
|
246
|
+
{
|
|
247
|
+
"table": table.name, # type: ignore[attr-defined]
|
|
248
|
+
"detail": "raw Table on the shared metadata with no mapped model class "
|
|
249
|
+
"(hand-rolled DDL outside the model layer)",
|
|
250
|
+
}
|
|
251
|
+
for table in live_tables
|
|
252
|
+
if table.name not in mapped_table_names # type: ignore[attr-defined]
|
|
253
|
+
]
|
|
254
|
+
unimported = [
|
|
255
|
+
{
|
|
256
|
+
"model": name,
|
|
257
|
+
"path": location[0],
|
|
258
|
+
"line": location[1],
|
|
259
|
+
"detail": "declared table=True in source but never imported onto the "
|
|
260
|
+
"shared metadata -- invisible to migrations AND to this schema view "
|
|
261
|
+
"until its module is imported from a models.py the app reaches",
|
|
262
|
+
}
|
|
263
|
+
for (name, module), location in sorted((source_models or {}).items())
|
|
264
|
+
if (name, module) not in mapped_models
|
|
265
|
+
]
|
|
266
|
+
return {
|
|
267
|
+
"tables": tables,
|
|
268
|
+
"unowned_tables": unowned,
|
|
269
|
+
"non_canonical_models": non_canonical,
|
|
270
|
+
"unmapped_tables": unmapped,
|
|
271
|
+
"unimported_models": unimported,
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _render_schema_text(graph: dict[str, object]) -> str:
|
|
276
|
+
lines = ["Schema graph", ""]
|
|
277
|
+
for table in graph["tables"]: # type: ignore[index, union-attr]
|
|
278
|
+
owner = table["module"] or "<unowned>"
|
|
279
|
+
traits = table["traits"]
|
|
280
|
+
flags = ",".join(
|
|
281
|
+
name
|
|
282
|
+
for name in ("soft_delete", "owned", "tenant_scoped", "actor_stamped")
|
|
283
|
+
if traits[name]
|
|
284
|
+
)
|
|
285
|
+
lines.append(
|
|
286
|
+
f" {table['name']:24} {owner:12} {table['kind'] or '!':10} "
|
|
287
|
+
f"{table['model']:20} {flags or '-'}"
|
|
288
|
+
)
|
|
289
|
+
for key, header in (
|
|
290
|
+
("unowned_tables", "! UNOWNED tables (no migration tree manages them):"),
|
|
291
|
+
("non_canonical_models", "! NON-CANONICAL models (no BaseTable lineage):"),
|
|
292
|
+
("unmapped_tables", "! UNMAPPED raw tables on the metadata:"),
|
|
293
|
+
("unimported_models", "! UNIMPORTED table models (declared in source, never loaded):"),
|
|
294
|
+
):
|
|
295
|
+
entries: list = graph[key] # type: ignore[assignment]
|
|
296
|
+
if entries:
|
|
297
|
+
lines.append("")
|
|
298
|
+
lines.append(header)
|
|
299
|
+
for entry in entries:
|
|
300
|
+
subject = entry.get("table") or entry.get("model")
|
|
301
|
+
lines.append(f" {subject}: {entry['detail']}")
|
|
302
|
+
return "\n".join(lines)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def render_schema_graph(graph: dict[str, object], fmt: str = "text") -> str:
|
|
306
|
+
"""Render a prebuilt schema *graph* as ``text`` or ``json``."""
|
|
307
|
+
if fmt == "json":
|
|
308
|
+
return json.dumps(graph, indent=2)
|
|
309
|
+
return _render_schema_text(graph)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
__all__ = [
|
|
313
|
+
"build_schema_graph",
|
|
314
|
+
"import_declared_models",
|
|
315
|
+
"render_schema_graph",
|
|
316
|
+
"scan_declared_table_models",
|
|
317
|
+
]
|