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