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/dev.py ADDED
@@ -0,0 +1,145 @@
1
+ """``terp dev`` — run the backend and frontend dev servers together (with an OpenAPI preflight).
2
+
3
+ The full-stack dev loop of design §7: one command boots the API (uvicorn ``--reload``) and the
4
+ frontend dev server side by side, after refreshing the OpenAPI document the frontend contract is
5
+ generated from — so the typed client's source is current before the servers start. A repo with no
6
+ ``frontend/`` directory (a backend-only app) runs just the API server.
7
+
8
+ The command is a pure planner (:func:`dev_plan`, which computes the two process commands) plus a
9
+ thin executor (:func:`run_dev_command`) with the process spawn/supervise primitives injected, so
10
+ the orchestration is fully testable without launching real servers.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import pathlib
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+ import time
20
+ from collections.abc import Callable, Sequence
21
+ from dataclasses import dataclass
22
+
23
+ from terp.cli.openapi import export_openapi
24
+
25
+ _POLL_SECONDS = 0.2
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class DevCommand:
30
+ """One dev process: a label, the argv to launch, and its working directory."""
31
+
32
+ label: str
33
+ argv: tuple[str, ...]
34
+ cwd: pathlib.Path
35
+
36
+
37
+ def dev_plan(
38
+ *,
39
+ app_ref: str = "app.main:app",
40
+ root: str | pathlib.Path = ".",
41
+ frontend_dir: str = "frontend",
42
+ host: str = "127.0.0.1",
43
+ port: int = 8000,
44
+ ) -> tuple[DevCommand, DevCommand]:
45
+ """Pure: the ``(backend, frontend)`` commands ``terp dev`` runs.
46
+
47
+ Backend = ``uvicorn <app_ref> --reload`` from the project root; frontend = ``npm run dev``
48
+ from ``<root>/<frontend_dir>`` (the copier template + example layout). The frontend command
49
+ is returned unconditionally; the executor runs it only when its directory exists.
50
+ """
51
+ root_path = pathlib.Path(root).resolve()
52
+ backend = DevCommand(
53
+ label="backend",
54
+ argv=(
55
+ sys.executable,
56
+ "-m",
57
+ "uvicorn",
58
+ app_ref,
59
+ "--reload",
60
+ "--host",
61
+ host,
62
+ "--port",
63
+ str(port),
64
+ ),
65
+ cwd=root_path,
66
+ )
67
+ frontend = DevCommand(
68
+ label="frontend",
69
+ argv=("npm", "run", "dev"),
70
+ cwd=root_path / frontend_dir,
71
+ )
72
+ return backend, frontend
73
+
74
+
75
+ Spawn = Callable[[DevCommand], "subprocess.Popen[bytes]"]
76
+ Supervise = Callable[[Sequence["subprocess.Popen[bytes]"]], None]
77
+
78
+
79
+ def _spawn(command: DevCommand) -> subprocess.Popen[bytes]:
80
+ """Start one dev process, resolving its executable on PATH (so ``npm`` works on Windows)."""
81
+ executable = shutil.which(command.argv[0]) or command.argv[0]
82
+ # The argv is an internally composed dev command (uvicorn / npm from dev_plan), run with
83
+ # shell=False, so there is no shell interpolation of untrusted input.
84
+ return subprocess.Popen( # noqa: S603 - internal dev argv, shell=False (no injection)
85
+ (executable, *command.argv[1:]), cwd=command.cwd
86
+ )
87
+
88
+
89
+ def _supervise(
90
+ processes: Sequence[subprocess.Popen[bytes]],
91
+ *,
92
+ sleep: Callable[[float], None] = time.sleep,
93
+ ) -> None:
94
+ """Block until one process exits, then terminate the rest (a crash or Ctrl+C stops both)."""
95
+ while all(process.poll() is None for process in processes):
96
+ sleep(_POLL_SECONDS)
97
+ for process in processes:
98
+ if process.poll() is None:
99
+ process.terminate()
100
+
101
+
102
+ def run_dev_command(
103
+ *,
104
+ app_ref: str = "app.main:app",
105
+ root: str | pathlib.Path = ".",
106
+ frontend_dir: str = "frontend",
107
+ host: str = "127.0.0.1",
108
+ port: int = 8000,
109
+ openapi_out: str = "openapi.json",
110
+ preflight: bool = True,
111
+ export: Callable[..., pathlib.Path] = export_openapi,
112
+ spawn: Spawn = _spawn,
113
+ supervise: Supervise = _supervise,
114
+ ) -> str:
115
+ """Run the backend + frontend dev servers together, after an OpenAPI preflight.
116
+
117
+ The preflight writes the live app's OpenAPI document (the frontend contract's codegen source)
118
+ so the typed client is current before the servers start; pass ``preflight=False`` to skip it.
119
+ uvicorn (``--reload``) and the frontend dev server then run side by side until one exits or is
120
+ interrupted, when the other is stopped too. A repo without ``<frontend_dir>/`` runs backend-only.
121
+
122
+ *export* / *spawn* / *supervise* are injected so the orchestration is testable without launching
123
+ real servers. Returns a one-line summary of what was stopped.
124
+ """
125
+ root_path = pathlib.Path(root).resolve()
126
+ backend, frontend = dev_plan(
127
+ app_ref=app_ref, root=root_path, frontend_dir=frontend_dir, host=host, port=port
128
+ )
129
+ if preflight:
130
+ destination = export(app_ref, out=root_path / openapi_out, app_root=root_path)
131
+ print(f"terp dev — OpenAPI preflight wrote {destination}")
132
+
133
+ commands = [backend]
134
+ if frontend.cwd.is_dir():
135
+ commands.append(frontend)
136
+ for command in commands:
137
+ print(f" {command.label:8} → {' '.join(command.argv)} (cwd {command.cwd})")
138
+
139
+ processes = [spawn(command) for command in commands]
140
+ supervise(processes)
141
+ ran = " + ".join(command.label for command in commands)
142
+ return f"terp dev stopped ({ran})"
143
+
144
+
145
+ __all__ = ["DevCommand", "dev_plan", "run_dev_command"]
terp/cli/docker.py ADDED
@@ -0,0 +1,57 @@
1
+ """``terp docker dev`` — the full-stack workbench: Postgres + backend + frontend via Compose watch.
2
+
3
+ Wraps ``docker compose -f <file> watch`` (Compose v2.22+): it brings up the database, runs the
4
+ one-shot migrate + seed, starts the API (uvicorn) and the frontend (Vite), and live-syncs source
5
+ into the running containers. One command from a checkout to a seeded, running app.
6
+
7
+ A pure planner (:func:`docker_dev_argv`) plus a thin executor (:func:`run_docker_dev_command`)
8
+ with the process runner injected, so the orchestration is testable without Docker.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pathlib
14
+ import shutil
15
+ import subprocess
16
+ from collections.abc import Callable, Sequence
17
+
18
+ _DEFAULT_COMPOSE = "docker-compose.yml"
19
+
20
+ Runner = Callable[[Sequence[str]], int]
21
+
22
+
23
+ def docker_dev_argv(
24
+ compose_file: str | pathlib.Path, *, project_name: str | None = None
25
+ ) -> tuple[str, ...]:
26
+ """The ``docker compose`` argv that runs the workbench with file-watching."""
27
+ argv = ["docker", "compose", "-f", str(compose_file)]
28
+ if project_name:
29
+ argv += ["-p", project_name]
30
+ argv.append("watch")
31
+ return tuple(argv)
32
+
33
+
34
+ def _run(argv: Sequence[str]) -> int:
35
+ """Run *argv*, resolving the executable on PATH (so ``docker`` works on Windows)."""
36
+ executable = shutil.which(argv[0]) or argv[0]
37
+ return subprocess.call([executable, *argv[1:]]) # noqa: S603 - fixed argv, shell=False
38
+
39
+
40
+ def run_docker_dev_command(
41
+ *,
42
+ compose_file: str = _DEFAULT_COMPOSE,
43
+ root: str | pathlib.Path = ".",
44
+ project_name: str | None = None,
45
+ runner: Runner | None = None,
46
+ ) -> str:
47
+ """Resolve the compose file under *root* and run ``docker compose watch``.
48
+
49
+ A missing compose file fails closed with a clean CLI error. *runner* is injected in tests so
50
+ the orchestration is verified without Docker; it returns the exit status of the watch process.
51
+ """
52
+ candidate = pathlib.Path(compose_file)
53
+ path = candidate if candidate.is_absolute() else pathlib.Path(root).resolve() / candidate
54
+ if not path.is_file():
55
+ raise SystemExit(f"compose file not found: {path} (looked under --root {root!r})")
56
+ status = (runner or _run)(docker_dev_argv(path, project_name=project_name))
57
+ return f"docker compose watch exited with status {status}"
terp/cli/jobs.py ADDED
@@ -0,0 +1,238 @@
1
+ """``terp jobs`` — run a job (the external-scheduler trigger) and inspect the catalog.
2
+
3
+ The jobs seam (ADR 0043) is driven from the CLI two ways:
4
+
5
+ * ``terp jobs run <name> --payload <json>`` is the most abstract possible scheduler —
6
+ any cron / k8s CronJob / systemd timer / Azure timer invokes it, so a scheduled job
7
+ works today with zero broker infra. It builds the app (so ``create_app`` has configured
8
+ the live :class:`~terp.core.JobCatalog` + queue), resolves the named job, validates the
9
+ JSON payload against its schema, and enqueues it through the typed
10
+ :func:`terp.core.enqueue` chokepoint.
11
+ * ``terp jobs list`` / ``terp inspect jobs`` render the registered jobs straight from the
12
+ control plane's catalog (like ``terp inspect control-plane`` renders the authority map),
13
+ so the listing is generated and cannot drift from what the app actually runs.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import contextlib
19
+ import importlib
20
+ import json
21
+ import pathlib
22
+ import sys
23
+
24
+ from fastapi import FastAPI
25
+
26
+ from terp.core import ControlPlane, enqueue
27
+ from terp.core.db import get_session
28
+ from terp.core.jobs import active_job_catalog
29
+
30
+
31
+ def _load_control_plane(dotted: str) -> ControlPlane:
32
+ """Resolve a ``module:attribute`` reference to a :class:`~terp.core.ControlPlane`."""
33
+ module_name, _, attr = dotted.partition(":")
34
+ if not module_name:
35
+ raise SystemExit(f"{dotted!r} is not a valid 'module:attribute' reference")
36
+ module = importlib.import_module(module_name)
37
+ candidate = getattr(module, attr or "control_plane")
38
+ if not isinstance(candidate, ControlPlane):
39
+ raise SystemExit(f"{dotted!r} did not resolve to a terp.core.ControlPlane instance")
40
+ return candidate
41
+
42
+
43
+ def _load_app(dotted: str) -> FastAPI:
44
+ """Resolve a ``module:attribute`` reference to a FastAPI app (instance or factory).
45
+
46
+ Building the app runs ``create_app``, which configures the live job catalog + queue —
47
+ so ``terp jobs run`` enqueues against exactly what the app would run. Mirrors the
48
+ ``terp openapi`` loader.
49
+ """
50
+ module_name, _, attr = dotted.partition(":")
51
+ if not module_name:
52
+ raise SystemExit(f"{dotted!r} is not a valid 'module:attribute' reference")
53
+ module = importlib.import_module(module_name)
54
+ candidate = getattr(module, attr or "app")
55
+ if isinstance(candidate, FastAPI):
56
+ return candidate
57
+ if callable(candidate):
58
+ built = candidate()
59
+ if isinstance(built, FastAPI):
60
+ return built
61
+ raise SystemExit(f"{dotted!r} did not resolve to a FastAPI application")
62
+
63
+
64
+ def render_jobs(dotted: str = "control_plane:control_plane") -> str:
65
+ """Render the control plane's registered jobs (the ``jobs list`` / ``inspect jobs`` view).
66
+
67
+ Generated from the live :class:`~terp.core.JobCatalog`, so it always matches what the
68
+ app declares — name, routing queue, retry budget, and visibility — plus the configured
69
+ system actor a user-less job runs as.
70
+ """
71
+ plane = _load_control_plane(dotted)
72
+ lines = ["Jobs"]
73
+ if not plane.jobs.jobs:
74
+ lines.append(" <none declared>")
75
+ for job in sorted(plane.jobs.jobs, key=lambda item: item.name):
76
+ lines.append(
77
+ f" {job.name} queue={job.queue} visibility={job.visibility.value} "
78
+ f"retry={job.retry.max_attempts}x"
79
+ )
80
+ if plane.job_system_actor_id is not None:
81
+ lines.append("")
82
+ lines.append(f"System actor: {plane.job_system_actor_id}")
83
+ return "\n".join(lines)
84
+
85
+
86
+ def run_job_command(
87
+ name: str,
88
+ *,
89
+ payload: str = "{}",
90
+ app_ref: str = "app.main:app",
91
+ app_root: str | pathlib.Path = ".",
92
+ ) -> str:
93
+ """Build *app_ref*, resolve job *name*, validate *payload* JSON, and enqueue it.
94
+
95
+ *app_root* is placed first on ``sys.path`` so the app package imports when ``terp`` runs
96
+ as an installed console script. An unknown job name or malformed JSON fails closed with
97
+ a ``SystemExit`` (a clean CLI error), and the payload is validated against the job's
98
+ schema before enqueuing, so a bad trigger never reaches a handler.
99
+ """
100
+ root = str(pathlib.Path(app_root).resolve())
101
+ if root not in sys.path:
102
+ sys.path.insert(0, root)
103
+ _load_app(app_ref)
104
+ job = active_job_catalog().get(name)
105
+ if job is None:
106
+ raise SystemExit(
107
+ f"job {name!r} is not registered in the app's JobCatalog; "
108
+ f"run `terp jobs list` to see the declared jobs"
109
+ )
110
+ try:
111
+ data = json.loads(payload)
112
+ except json.JSONDecodeError as exc:
113
+ raise SystemExit(f"--payload is not valid JSON: {exc}") from exc
114
+ payload_obj = job.payload_schema.model_validate(data)
115
+ with contextlib.closing(get_session()) as gen:
116
+ session = next(gen)
117
+ job_id = enqueue(session, job=job, payload=payload_obj)
118
+ return f"enqueued {name!r} (job id {job_id})"
119
+
120
+
121
+ def run_worker_command(
122
+ *,
123
+ app_ref: str = "app.main:app",
124
+ app_root: str | pathlib.Path = ".",
125
+ max_cycles: int | None = None,
126
+ batch_size: int = 10,
127
+ lease_seconds: float = 30.0,
128
+ ) -> str:
129
+ """Build *app_ref*, then drain the durable outbox until empty (or *max_cycles*).
130
+
131
+ The worker-container entrypoint (ADR 0045). Building the app runs ``create_app``, so the
132
+ live :class:`~terp.core.JobCatalog` is configured (the worker's ``run_job`` resolves each
133
+ job by name) and the durable :class:`~terp.capabilities.outbox.OutboxJobQueue` is wired.
134
+ It then leases due ``outbox_message`` rows, runs jobs through the context-binding kernel
135
+ runner and events through the in-process handlers, and retries / dead-letters per each
136
+ job's :class:`~terp.core.RetryPolicy`. ``SKIP LOCKED`` is enabled automatically on
137
+ PostgreSQL; on SQLite the portable atomic-UPDATE lease is used. Requires the
138
+ ``terp-cap-outbox`` capability (an app wiring the durable queue already depends on
139
+ it; a standalone worker image installs ``terp-cli[worker]`` or the combined
140
+ ``terp-cli[jobs]`` extra).
141
+ """
142
+ root = str(pathlib.Path(app_root).resolve())
143
+ if root not in sys.path:
144
+ sys.path.insert(0, root)
145
+ _load_app(app_ref)
146
+
147
+ from sqlmodel import Session
148
+
149
+ try:
150
+ from terp.capabilities.outbox import OutboxWorker
151
+ except ImportError as exc:
152
+ raise SystemExit(
153
+ "terp jobs worker requires the terp-cap-outbox capability, which is not "
154
+ "installed. Add terp-cap-outbox to the app's dependencies (wiring the "
155
+ "durable OutboxJobQueue already requires it) or install `terp-cli[worker]` "
156
+ "(or `terp-cli[jobs]` for worker + scheduler support)."
157
+ ) from exc
158
+ from terp.core._internal.engine import get_engine
159
+
160
+ engine = get_engine()
161
+ worker = OutboxWorker(
162
+ lambda: Session(engine),
163
+ batch_size=batch_size,
164
+ lease_seconds=lease_seconds,
165
+ skip_locked=engine.dialect.name == "postgresql",
166
+ )
167
+ result = worker.run(max_cycles=max_cycles)
168
+ return (
169
+ f"outbox worker drained: claimed={result.claimed} dispatched={result.dispatched} "
170
+ f"retried={result.retried} dead-lettered={result.dead_lettered} lost={result.lost}"
171
+ )
172
+
173
+
174
+ def _default_scheduler() -> object:
175
+ """Build the default scheduler process: a blocking APScheduler wrapped by the adapter.
176
+
177
+ A dedicated ``terp jobs scheduler`` process runs APScheduler's ``BlockingScheduler`` (its
178
+ ``start`` blocks the main thread), driving each schedule's tick through the jobs seam.
179
+ Requires the ``terp-cap-scheduler-apscheduler`` capability.
180
+ """
181
+ from sqlmodel import Session
182
+
183
+ try:
184
+ from apscheduler.schedulers.blocking import BlockingScheduler
185
+
186
+ from terp.capabilities.scheduler_apscheduler import ApschedulerScheduler
187
+ except ImportError as exc:
188
+ raise SystemExit(
189
+ "terp jobs scheduler requires the terp-cap-scheduler-apscheduler "
190
+ "capability, which is not installed. Add terp-cap-scheduler-apscheduler "
191
+ "to the app's dependencies, install `terp-cli[scheduler]` (or the combined "
192
+ "`terp-cli[jobs]` extra), or run schedules with Celery beat."
193
+ ) from exc
194
+ from terp.core._internal.engine import get_engine
195
+
196
+ engine = get_engine()
197
+ return ApschedulerScheduler(lambda: Session(engine), scheduler=BlockingScheduler())
198
+
199
+
200
+ def run_scheduler_command(
201
+ *,
202
+ app_ref: str = "app.main:app",
203
+ app_root: str | pathlib.Path = ".",
204
+ scheduler: object | None = None,
205
+ ) -> str:
206
+ """Build *app_ref*, then run the in-process scheduler until stopped (the scheduler entrypoint).
207
+
208
+ The scheduler-process entrypoint deferred from ADR 0048: building the app runs ``create_app``,
209
+ so the live :class:`~terp.core.ScheduleCatalog` (and the :class:`~terp.core.JobCatalog` its
210
+ jobs resolve against) is configured. It registers every declared schedule with an
211
+ APScheduler-backed :class:`~terp.core.Scheduler` and starts it — each cron tick fires the
212
+ schedule through the typed :func:`~terp.core.enqueue` chokepoint, so a scheduled job flows
213
+ through the active :class:`~terp.core.JobQueue` (run it off-request with ``terp jobs worker``
214
+ when the durable outbox is wired). ``start`` **blocks** until the process is stopped (SIGINT).
215
+ *scheduler* is injectable for tests; the default wraps a blocking APScheduler and requires the
216
+ ``terp-cap-scheduler-apscheduler`` capability.
217
+ """
218
+ root = str(pathlib.Path(app_root).resolve())
219
+ if root not in sys.path:
220
+ sys.path.insert(0, root)
221
+ _load_app(app_ref)
222
+
223
+ from terp.core.scheduling import active_schedule_catalog
224
+
225
+ catalog = active_schedule_catalog()
226
+ scheduler = scheduler if scheduler is not None else _default_scheduler()
227
+ scheduler.register_all(catalog)
228
+ names = ", ".join(catalog.names()) or "<none>"
229
+ scheduler.start() # blocks until the process is stopped (a long-running daemon)
230
+ return f"scheduler stopped; {len(catalog.schedules)} schedule(s) registered: {names}"
231
+
232
+
233
+ __all__ = [
234
+ "render_jobs",
235
+ "run_job_command",
236
+ "run_scheduler_command",
237
+ "run_worker_command",
238
+ ]
terp/cli/openapi.py ADDED
@@ -0,0 +1,67 @@
1
+ """``terp openapi`` — export the app's OpenAPI document for the frontend contract.
2
+
3
+ The frontend contract's API client is *generated* from the backend OpenAPI (design
4
+ §7.1), so the two can never drift. This command writes that document straight from the
5
+ live FastAPI app — the same object ``create_app`` returns — into a JSON file the
6
+ frontend codegen consumes. It is the Python-side seam of Phase 4: no hand-rolled fetch
7
+ client and no second, hand-maintained schema (ADR 0041).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import importlib
13
+ import json
14
+ import pathlib
15
+ import sys
16
+ from typing import Any
17
+
18
+ from fastapi import FastAPI
19
+
20
+
21
+ def _load_app(dotted: str) -> FastAPI:
22
+ """Resolve a ``module:attribute`` reference to a FastAPI application.
23
+
24
+ Accepts either an app instance (``app.main:app``) or a zero-argument factory that
25
+ returns one (``app.main:build``), mirroring uvicorn's ``--factory`` convention.
26
+ """
27
+ module_name, _, attr = dotted.partition(":")
28
+ if not module_name:
29
+ raise SystemExit(f"{dotted!r} is not a valid 'module:attribute' reference")
30
+ module = importlib.import_module(module_name)
31
+ candidate = getattr(module, attr or "app")
32
+ if isinstance(candidate, FastAPI):
33
+ return candidate
34
+ if callable(candidate):
35
+ built = candidate()
36
+ if isinstance(built, FastAPI):
37
+ return built
38
+ raise SystemExit(f"{dotted!r} did not resolve to a FastAPI application")
39
+
40
+
41
+ def export_openapi(
42
+ app_ref: str = "app.main:app",
43
+ *,
44
+ out: str | pathlib.Path = "openapi.json",
45
+ app_root: str | pathlib.Path = ".",
46
+ ) -> pathlib.Path:
47
+ """Write *app_ref*'s OpenAPI document to *out* as JSON; return the path.
48
+
49
+ *app_root* is placed first on ``sys.path`` so the app package imports when ``terp``
50
+ runs as an installed console script (where the working directory is not on the path).
51
+ The output is sorted and indented, so a regenerated contract diffs cleanly.
52
+ """
53
+ root = str(pathlib.Path(app_root).resolve())
54
+ if root not in sys.path:
55
+ sys.path.insert(0, root)
56
+ app = _load_app(app_ref)
57
+ spec: dict[str, Any] = app.openapi()
58
+ destination = pathlib.Path(out)
59
+ destination.parent.mkdir(parents=True, exist_ok=True)
60
+ # newline="\n" keeps the generated artifact byte-stable across platforms, so a
61
+ # committed, drift-checked contract does not flip to CRLF when regenerated on Windows.
62
+ destination.write_text(
63
+ json.dumps(spec, indent=2, sort_keys=True) + "\n",
64
+ encoding="utf-8",
65
+ newline="\n",
66
+ )
67
+ return destination
terp/cli/profiles.py ADDED
@@ -0,0 +1,148 @@
1
+ """Permission profiles — named access models that compile to Terp primitives.
2
+
3
+ A profile is a **preset, never a new mechanism**: choosing one only decides which
4
+ existing, enforced primitives the scaffold composes — ``Policy`` for module /
5
+ endpoint access, model traits (``OwnedMixin`` / ``TenantScopedMixin``) for the
6
+ data layer, and the matching service base. The result is ordinary Terp code the
7
+ architecture gate checks like any hand-written module, and the access graph
8
+ (``terp inspect access``) renders exactly what was generated — the profile is
9
+ the UX layer; the typed declarations remain the enforceable contract.
10
+
11
+ Profiles are deliberately few and composable-by-name (a Studio can present them
12
+ as answers to "who can see / edit these records?"):
13
+
14
+ - ``shared`` authenticated app-wide rows; read VIEWER, write EDITOR
15
+ - ``role-gated`` like ``shared`` but mutations require ADMIN
16
+ - ``owner-private`` the creator owns each row; only the owner may update/delete
17
+ - ``tenant-private`` rows are isolated per tenant (reads filtered, writes stamped)
18
+ - ``tenant-owner`` tenant isolation + per-row owner write gate
19
+
20
+ Anything richer (a named ``Permission`` grant, a team/ACL predicate) starts from
21
+ one of these and layers the existing seams on top — see ``terp guide access``.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass, field
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ModuleProfile:
31
+ """One named permission profile the scaffold can compile to primitives."""
32
+
33
+ name: str
34
+ summary: str
35
+ #: names imported from ``terp.core`` in the generated ``module.py``
36
+ policy_imports: tuple[str, ...]
37
+ #: the ``Policy`` expression placed on the generated ``ModuleSpec``
38
+ policy_expr: str
39
+ #: extra ``terp.core`` names mixed into the generated table model
40
+ core_model_mixins: tuple[str, ...] = ()
41
+ #: extra import lines for the generated ``models.py`` (capability mixins)
42
+ model_import_lines: tuple[str, ...] = ()
43
+ #: extra class names (from *model_import_lines*) mixed into the model
44
+ capability_model_mixins: tuple[str, ...] = ()
45
+ #: the service base class name for the generated ``service.py``
46
+ service_base: str = "BaseService"
47
+ #: the import line providing *service_base*
48
+ service_import_line: str = "from terp.core import BaseService"
49
+ #: extra scaffold follow-up notes (wiring the profile depends on)
50
+ notes: tuple[str, ...] = field(default_factory=tuple)
51
+
52
+ @property
53
+ def model_mixins(self) -> tuple[str, ...]:
54
+ """Every mixin the generated model composes, in declaration order."""
55
+ return self.core_model_mixins + self.capability_model_mixins
56
+
57
+
58
+ PROFILES: dict[str, ModuleProfile] = {
59
+ "shared": ModuleProfile(
60
+ name="shared",
61
+ summary="Authenticated shared workspace: read VIEWER, write EDITOR (Policy.default()).",
62
+ policy_imports=("ModuleSpec", "Policy"),
63
+ policy_expr="Policy.default()",
64
+ ),
65
+ "role-gated": ModuleProfile(
66
+ name="role-gated",
67
+ summary="Reads for every authenticated VIEWER; mutations require ADMIN.",
68
+ policy_imports=("ADMIN", "VIEWER", "ModuleSpec", "Policy"),
69
+ policy_expr="Policy(read=VIEWER, write=ADMIN)",
70
+ ),
71
+ "owner-private": ModuleProfile(
72
+ name="owner-private",
73
+ summary=(
74
+ "The creator owns each row: BaseService stamps owner_id on create and "
75
+ "refuses a non-owner update/delete (OwnedMixin, ADR 0029)."
76
+ ),
77
+ policy_imports=("ModuleSpec", "Policy"),
78
+ policy_expr="Policy.default()",
79
+ core_model_mixins=("OwnedMixin",),
80
+ notes=(
81
+ "OwnedMixin gates writes only; to also hide other owners' rows from reads,"
82
+ " register a scope predicate (terp guide ownership).",
83
+ ),
84
+ ),
85
+ "tenant-private": ModuleProfile(
86
+ name="tenant-private",
87
+ summary=(
88
+ "Rows are isolated per tenant: every read is filtered to the current "
89
+ "tenant and create stamps tenant_id (TenantScopedMixin + TenantScopedService)."
90
+ ),
91
+ policy_imports=("ModuleSpec", "Policy"),
92
+ policy_expr="Policy.default()",
93
+ model_import_lines=("from terp.capabilities.tenancy import TenantScopedMixin",),
94
+ capability_model_mixins=("TenantScopedMixin",),
95
+ service_base="TenantScopedService",
96
+ service_import_line="from terp.capabilities.tenancy import TenantScopedService",
97
+ notes=(
98
+ "Wire the tenant context at the composition root:"
99
+ " create_app(..., middleware=[Middleware(TenantMiddleware, ...)])"
100
+ " (terp guide tenancy).",
101
+ ),
102
+ ),
103
+ "tenant-owner": ModuleProfile(
104
+ name="tenant-owner",
105
+ summary=(
106
+ "Tenant isolation plus a per-row owner write gate: reads are tenant-"
107
+ "filtered, and only a row's owner may update/delete it."
108
+ ),
109
+ policy_imports=("ModuleSpec", "Policy"),
110
+ policy_expr="Policy.default()",
111
+ core_model_mixins=("OwnedMixin",),
112
+ model_import_lines=("from terp.capabilities.tenancy import TenantScopedMixin",),
113
+ capability_model_mixins=("TenantScopedMixin",),
114
+ service_base="TenantScopedService",
115
+ service_import_line="from terp.capabilities.tenancy import TenantScopedService",
116
+ notes=(
117
+ "Wire the tenant context at the composition root (terp guide tenancy).",
118
+ "OwnedMixin gates writes only; register a scope predicate for owner-"
119
+ "filtered reads (terp guide ownership).",
120
+ ),
121
+ ),
122
+ }
123
+
124
+ DEFAULT_PROFILE = "shared"
125
+
126
+
127
+ def profile_names() -> tuple[str, ...]:
128
+ """Every profile name, sorted — the CLI ``choices`` source of truth."""
129
+ return tuple(sorted(PROFILES))
130
+
131
+
132
+ def get_profile(name: str) -> ModuleProfile:
133
+ """Resolve *name* to a profile, failing closed with the valid choices."""
134
+ try:
135
+ return PROFILES[name]
136
+ except KeyError:
137
+ raise SystemExit(
138
+ f"unknown profile {name!r}: choose one of {', '.join(profile_names())}"
139
+ ) from None
140
+
141
+
142
+ __all__ = [
143
+ "DEFAULT_PROFILE",
144
+ "PROFILES",
145
+ "ModuleProfile",
146
+ "get_profile",
147
+ "profile_names",
148
+ ]
terp/cli/py.typed ADDED
File without changes