terp-cli 0.31.0__tar.gz → 0.32.0__tar.gz

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.
Files changed (39) hide show
  1. {terp_cli-0.31.0 → terp_cli-0.32.0}/PKG-INFO +8 -8
  2. {terp_cli-0.31.0 → terp_cli-0.32.0}/pyproject.toml +8 -8
  3. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/docker.py +14 -0
  4. terp_cli-0.32.0/src/terp/cli/docker_masks.py +167 -0
  5. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/verify.py +18 -0
  6. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/version.py +10 -3
  7. {terp_cli-0.31.0 → terp_cli-0.32.0}/.gitignore +0 -0
  8. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/__init__.py +0 -0
  9. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/_appref.py +0 -0
  10. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/_engine.py +0 -0
  11. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/_output.py +0 -0
  12. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/_subjects.py +0 -0
  13. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/access.py +0 -0
  14. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/apidocs.py +0 -0
  15. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/authz_surface.py +0 -0
  16. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/capabilities.py +0 -0
  17. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/deploy_safety.py +0 -0
  18. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/dev.py +0 -0
  19. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/envfile.py +0 -0
  20. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/envschema.py +0 -0
  21. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/envseams.py +0 -0
  22. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/fmt.py +0 -0
  23. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/grants.py +0 -0
  24. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/jobs.py +0 -0
  25. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/leases.py +0 -0
  26. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/module_roles.py +0 -0
  27. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/openapi.py +0 -0
  28. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/outbox.py +0 -0
  29. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/ports.py +0 -0
  30. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/profiles.py +0 -0
  31. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/py.typed +0 -0
  32. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/routes.py +0 -0
  33. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/scaffold.py +0 -0
  34. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/schema.py +0 -0
  35. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/seed.py +0 -0
  36. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/service_accounts.py +0 -0
  37. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/smoke.py +0 -0
  38. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/users.py +0 -0
  39. {terp_cli-0.31.0 → terp_cli-0.32.0}/src/terp/cli/workbench.py +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: terp-cli
3
- Version: 0.31.0
3
+ Version: 0.32.0
4
4
  Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
5
5
  Project-URL: Repository, https://github.com/AITT-NL/terp-framework
6
6
  Project-URL: Changelog, https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md
7
7
  License-Expression: Apache-2.0
8
8
  Requires-Python: >=3.13
9
9
  Requires-Dist: pyyaml>=6.0
10
- Requires-Dist: terp-arch==0.31.0
11
- Requires-Dist: terp-core==0.31.0
12
- Requires-Dist: terp-migrations==0.31.0
10
+ Requires-Dist: terp-arch==0.32.0
11
+ Requires-Dist: terp-core==0.32.0
12
+ Requires-Dist: terp-migrations==0.32.0
13
13
  Provides-Extra: jobs
14
- Requires-Dist: terp-cap-outbox==0.31.0; extra == 'jobs'
15
- Requires-Dist: terp-cap-scheduler-apscheduler==0.31.0; extra == 'jobs'
14
+ Requires-Dist: terp-cap-outbox==0.32.0; extra == 'jobs'
15
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.32.0; extra == 'jobs'
16
16
  Provides-Extra: scheduler
17
- Requires-Dist: terp-cap-scheduler-apscheduler==0.31.0; extra == 'scheduler'
17
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.32.0; extra == 'scheduler'
18
18
  Provides-Extra: worker
19
- Requires-Dist: terp-cap-outbox==0.31.0; extra == 'worker'
19
+ Requires-Dist: terp-cap-outbox==0.32.0; extra == 'worker'
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "terp-cli"
7
- version = "0.31.0"
7
+ version = "0.32.0"
8
8
  description = "Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs."
9
9
  requires-python = ">=3.13"
10
10
  license = "Apache-2.0"
11
11
  dependencies = [
12
- "terp-core==0.31.0",
12
+ "terp-core==0.32.0",
13
13
  # `terp migrate` delegates to terp-migrations (lazily imported), keeping Alembic
14
14
  # off the path for `terp inspect` / `terp guide` while making migrations work.
15
- "terp-migrations==0.31.0",
15
+ "terp-migrations==0.32.0",
16
16
  # `terp guide rules` projects the live rule registry; terp-arch is imported lazily
17
17
  # (only when that topic is rendered), so it stays off the common `terp guide` path.
18
- "terp-arch==0.31.0",
18
+ "terp-arch==0.32.0",
19
19
  # `terp verify --only env-seams` and `terp smoke` read the compose profiles as data
20
20
  # (anchors and `<<:` merge keys resolved), so both answer without a Docker daemon or
21
21
  # even the `docker` binary. Declared explicitly rather than leant on: every app
@@ -29,11 +29,11 @@ dependencies = [
29
29
  # or a scheduler engine into apps that only use inspect/check/migrate. Deployments can select
30
30
  # one process role, while `jobs` is the convenient complete jobs-process bundle.
31
31
  [project.optional-dependencies]
32
- worker = ["terp-cap-outbox==0.31.0"]
33
- scheduler = ["terp-cap-scheduler-apscheduler==0.31.0"]
32
+ worker = ["terp-cap-outbox==0.32.0"]
33
+ scheduler = ["terp-cap-scheduler-apscheduler==0.32.0"]
34
34
  jobs = [
35
- "terp-cap-outbox==0.31.0",
36
- "terp-cap-scheduler-apscheduler==0.31.0",
35
+ "terp-cap-outbox==0.32.0",
36
+ "terp-cap-scheduler-apscheduler==0.32.0",
37
37
  ]
38
38
 
39
39
  [project.scripts]
@@ -29,6 +29,8 @@ import subprocess
29
29
  from collections.abc import Callable, Sequence
30
30
 
31
31
  from terp.cli import ports
32
+ from terp.cli._output import emit
33
+ from terp.cli.docker_masks import clear_stale_masks
32
34
 
33
35
  _DEFAULT_COMPOSE = "docker-compose.yml"
34
36
 
@@ -229,6 +231,18 @@ def run_docker_dev_command(
229
231
  _, note = ports.ensure_assigned(path.parent)
230
232
  if note:
231
233
  print(note)
234
+ # Before `watch` creates anything: a workbench from before 0.30.0 holds its
235
+ # node_modules mask as a volume, which Compose would reattach over the tmpfs the
236
+ # compose file now declares and keep serving old packages from
237
+ # (terp.cli.docker_masks). No docker on PATH is `watch`'s to report, in its own
238
+ # words, not this check's.
239
+ try:
240
+ cleared = clear_stale_masks(path, project_name=project_name, capture=capture or _capture)
241
+ except OSError:
242
+ cleared = ""
243
+ if cleared:
244
+ # Through the CLI's one output seam, and flushed: `watch` holds the terminal next.
245
+ emit(cleared)
232
246
  status = (runner or _run)(docker_dev_argv(path, project_name=project_name))
233
247
  message = f"docker compose watch exited with status {status}"
234
248
  if status == 0:
@@ -0,0 +1,167 @@
1
+ """A mask the compose file declares as ``tmpfs`` that a running workbench still holds as a volume.
2
+
3
+ 0.30.0 turned the frontend's ``node_modules`` mask from an anonymous volume into a
4
+ ``tmpfs``, so that a dependency bump reaches the running dev server. A workbench that
5
+ was already up before the upgrade does not get it. When Compose recreates a container
6
+ it reattaches the old container's anonymous volumes by target path, and that wins over
7
+ the ``tmpfs`` now declared at the same path. The dev server then keeps serving the
8
+ first boot's packages from that volume, with every container healthy and the page
9
+ answering 200. An upgraded-on-paper workbench and an upgraded one look identical.
10
+
11
+ Compose owns that reattachment, so the place to undo it is before ``watch`` creates
12
+ anything. For every service whose mount at a ``tmpfs`` path is still a volume, the
13
+ container is removed, and so is that volume and only that one. Compose then creates
14
+ both fresh, with the ``tmpfs``. The volume held nothing but a copy of the image's
15
+ packages: the mask exists to hide the checkout's ``node_modules``, never to keep one.
16
+ A named volume is never touched, and neither is any other volume of the container.
17
+
18
+ Best effort, like the failure diagnosis next door: a daemon that is not up, a compose
19
+ file Compose cannot read, or a ``ps`` with no containers yet means there is nothing to
20
+ undo, and ``watch`` speaks for itself.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import pathlib
27
+ from collections.abc import Callable, Sequence
28
+
29
+ #: Runs argv, returning ``(exit status, combined output)``.
30
+ Capture = Callable[[Sequence[str]], tuple[int, str]]
31
+
32
+
33
+ def _compose(compose_file: pathlib.Path, project_name: str | None) -> list[str]:
34
+ argv = ["docker", "compose", "-f", str(compose_file)]
35
+ if project_name:
36
+ argv += ["-p", project_name]
37
+ return argv
38
+
39
+
40
+ def _json_entries(output: str) -> list[dict]:
41
+ """A JSON array, one object, or newline-delimited objects, as Compose has printed each."""
42
+ stripped = output.strip()
43
+ if not stripped:
44
+ return []
45
+ try:
46
+ parsed = json.loads(stripped)
47
+ except json.JSONDecodeError:
48
+ entries = []
49
+ for line in stripped.splitlines():
50
+ try:
51
+ entries.append(json.loads(line))
52
+ except json.JSONDecodeError:
53
+ continue
54
+ return [entry for entry in entries if isinstance(entry, dict)]
55
+ items = parsed if isinstance(parsed, list) else [parsed]
56
+ return [entry for entry in items if isinstance(entry, dict)]
57
+
58
+
59
+ def tmpfs_targets(config_output: str) -> dict[str, frozenset[str]]:
60
+ """Per service, the paths ``docker compose config --format json`` declares as ``tmpfs``.
61
+
62
+ Both spellings count: a ``volumes`` entry of ``type: tmpfs``, and the service-level
63
+ ``tmpfs:`` list (whose entries may carry ``:options`` after the path).
64
+ """
65
+ try:
66
+ config = json.loads(config_output)
67
+ except json.JSONDecodeError:
68
+ return {}
69
+ services = config.get("services") if isinstance(config, dict) else None
70
+ if not isinstance(services, dict):
71
+ return {}
72
+ found: dict[str, frozenset[str]] = {}
73
+ for name, service in services.items():
74
+ if not isinstance(service, dict):
75
+ continue
76
+ targets = {
77
+ volume["target"]
78
+ for volume in service.get("volumes") or ()
79
+ if isinstance(volume, dict)
80
+ and volume.get("type") == "tmpfs"
81
+ and isinstance(volume.get("target"), str)
82
+ }
83
+ listed = service.get("tmpfs") or ()
84
+ for entry in [listed] if isinstance(listed, str) else listed:
85
+ if isinstance(entry, str) and entry:
86
+ targets.add(entry.split(":", 1)[0])
87
+ if targets:
88
+ found[name] = frozenset(targets)
89
+ return found
90
+
91
+
92
+ def containers(ps_output: str) -> dict[str, str]:
93
+ """Service name to container id, from ``docker compose ps --all --format json``."""
94
+ found: dict[str, str] = {}
95
+ for entry in _json_entries(ps_output):
96
+ service, container = entry.get("Service"), entry.get("ID")
97
+ if isinstance(service, str) and isinstance(container, str) and container:
98
+ found.setdefault(service, container)
99
+ return found
100
+
101
+
102
+ def stale_volumes(inspect_output: str, targets: frozenset[str]) -> tuple[str, ...]:
103
+ """The volumes ``docker inspect`` shows mounted at a path that should be a ``tmpfs``.
104
+
105
+ Only a volume counts, never a bind: a bind at that path is somebody's deliberate
106
+ choice, and removing it is not this command's to decide.
107
+ """
108
+ names: list[str] = []
109
+ for container in _json_entries(inspect_output):
110
+ for mount in container.get("Mounts") or ():
111
+ if (
112
+ isinstance(mount, dict)
113
+ and mount.get("Type") == "volume"
114
+ and mount.get("Destination") in targets
115
+ and isinstance(mount.get("Name"), str)
116
+ and mount["Name"] not in names
117
+ ):
118
+ names.append(mount["Name"])
119
+ return tuple(names)
120
+
121
+
122
+ def clear_stale_masks(
123
+ compose_file: pathlib.Path,
124
+ *,
125
+ project_name: str | None = None,
126
+ capture: Capture,
127
+ ) -> str:
128
+ """Undo a pre-0.30.0 ``node_modules`` volume before ``watch``; what was done, or ``""``."""
129
+ compose = _compose(compose_file, project_name)
130
+ status, config = capture([*compose, "config", "--format", "json"])
131
+ targets = tmpfs_targets(config) if status == 0 else {}
132
+ if not targets:
133
+ return ""
134
+ status, ps_output = capture([*compose, "ps", "--all", "--format", "json"])
135
+ running = containers(ps_output) if status == 0 else {}
136
+ stale: dict[str, tuple[str, ...]] = {}
137
+ for service, paths in sorted(targets.items()):
138
+ container = running.get(service)
139
+ if container is None:
140
+ continue
141
+ status, inspected = capture(["docker", "inspect", container])
142
+ volumes = stale_volumes(inspected, paths) if status == 0 else ()
143
+ if volumes:
144
+ stale[service] = volumes
145
+ if not stale:
146
+ return ""
147
+ services = sorted(stale)
148
+ volumes = [volume for service in services for volume in stale[service]]
149
+ listing = ", ".join(repr(service) for service in services)
150
+ removed, _ = capture([*compose, "rm", "--stop", "--force", *services])
151
+ if removed == 0:
152
+ removed, _ = capture(["docker", "volume", "rm", *volumes])
153
+ if removed != 0:
154
+ renew = [*compose, "up", "-d", "--no-deps", "--force-recreate", "--renew-anon-volumes"]
155
+ return (
156
+ f"terp docker dev: {listing} still mount(s) a volume where the compose file "
157
+ "declares a tmpfs (a workbench from before 0.30.0), so the dev server would keep "
158
+ "serving its old packages. Removing it failed; once, by hand:\n"
159
+ f" {' '.join([*renew, *services])}\n"
160
+ f" docker volume rm {' '.join(volumes)}"
161
+ )
162
+ return (
163
+ f"terp docker dev: {listing} still mounted a volume where the compose file declares "
164
+ "a tmpfs (a workbench from before 0.30.0), so the dev server kept serving the packages "
165
+ "it first booted with. Removed the container and that volume; this start creates both "
166
+ "fresh."
167
+ )
@@ -1043,6 +1043,24 @@ def _node_modules_problem(root: pathlib.Path, workspace: str) -> str | None:
1043
1043
  except (OSError, ValueError):
1044
1044
  return None
1045
1045
 
1046
+ # A directory with none of the lockfile's platform-neutral packages in it is not
1047
+ # a tree for another platform but no tree at all -- an install that never ran,
1048
+ # or a fresh mount point. Calling it "a different platform" sends the reader
1049
+ # looking for a second machine that does not exist. Neutral ones only: every
1050
+ # platform installs those, so not one of them present means nothing was.
1051
+ neutral = [
1052
+ name
1053
+ for name, entry in packages.items()
1054
+ if name.startswith("node_modules/")
1055
+ and isinstance(entry, dict)
1056
+ and not (entry.get("os") or entry.get("cpu") or entry.get("libc"))
1057
+ ]
1058
+ if neutral and not any((directory / name).exists() for name in neutral):
1059
+ return (
1060
+ f"{label} is missing (the directory is there, but holds none of the "
1061
+ f"lockfile's packages) — {subject} cannot run.\n Fix: {fix}"
1062
+ )
1063
+
1046
1064
  system, arch = _node_platform()
1047
1065
  libc = _node_libc(system)
1048
1066
  missing = [
@@ -236,6 +236,7 @@ _APP_OWNED_SCAFFOLD_FILES = (
236
236
  "control_plane/app_operations.py",
237
237
  "environment.schema.json",
238
238
  "escape-hatch-budget.json",
239
+ "frontend/escape-hatch-budget.json",
239
240
  "frontend/layout-contract.json",
240
241
  "frontend/src/house-style.css",
241
242
  "frontend/src/routes.gen.d.ts",
@@ -450,8 +451,11 @@ def _rerender_recipe(target: str, current: str, count: int) -> list[str]:
450
451
  " and its own diff is much easier to review on its own.",
451
452
  " 3. copier update (or the Studio's upgrade flow, which records the",
452
453
  " answers file it needs). This rewrites EVERY file the template owns and",
453
- " writes the terp-* and @terpjs/* pins for you.",
454
- " 4. Resolve what it reports. Two conflicts are structural rather than bad luck,",
454
+ " writes the terp-* and @terpjs/* pins for you. Its --pretend lists none of",
455
+ " that, so do not judge the update by a dry run: review what it did as its",
456
+ " own diff (git status, git diff) — that is why step 2 cleans the tree.",
457
+ " 4. Resolve what it reports: conflict markers in the file, or a *.rej beside it",
458
+ " when it ran with --conflict rej. Two conflicts are structural, not bad luck,",
455
459
  " because the template owns the file and your app also writes to it:",
456
460
  " pyproject.toml keep your dependencies, take the terp-* pins.",
457
461
  " control_plane/operations.py the capability folding is the template's;",
@@ -468,7 +472,10 @@ def _rerender_recipe(target: str, current: str, count: int) -> list[str]:
468
472
  " in while the source is bind-mounted, so correct new code reloads against old",
469
473
  " libraries and dies on an import nowhere near its cause. `terp docker dev`",
470
474
  " rebuilds on a pyproject.toml change; a plain `docker compose up` does not,",
471
- " and `terp verify` now refuses the skew either way.",
475
+ " and `terp verify` now refuses the skew either way. Coming from 0.29 or older,",
476
+ " a stack that is already up keeps its old node_modules volume where 0.30.0",
477
+ " declares a tmpfs. `terp docker dev` removes it before it starts; with plain",
478
+ " compose, run `up --force-recreate --renew-anon-volumes` once.",
472
479
  "",
473
480
  "A green gate proves the upgrade did not break this app. It cannot prove the",
474
481
  "release did not change something this app should adopt — step 1 is the only",
File without changes
File without changes
File without changes