parse-sdk 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.
- parse_sdk/__init__.py +57 -0
- parse_sdk/_labels.py +51 -0
- parse_sdk/_oauth.py +301 -0
- parse_sdk/_project.py +184 -0
- parse_sdk/_runtime.py +2142 -0
- parse_sdk/_sanitize.py +15 -0
- parse_sdk/_sync.py +887 -0
- parse_sdk/checks.py +601 -0
- parse_sdk/cli.py +1667 -0
- parse_sdk/cli_help.py +110 -0
- parse_sdk/codegen_reconcile.py +157 -0
- parse_sdk/codegen_v2.py +3361 -0
- parse_sdk/config.py +349 -0
- parse_sdk/docgen.py +587 -0
- parse_sdk/doctor.py +348 -0
- parse_sdk/migrate.py +212 -0
- parse_sdk/preview.py +588 -0
- parse_sdk/py.typed +0 -0
- parse_sdk/resource_surface.py +196 -0
- parse_sdk/sample_gate.py +245 -0
- parse_sdk/scaffold.py +273 -0
- parse_sdk-0.1.0.dist-info/METADATA +177 -0
- parse_sdk-0.1.0.dist-info/RECORD +26 -0
- parse_sdk-0.1.0.dist-info/WHEEL +4 -0
- parse_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- parse_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
parse_sdk/_sync.py
ADDED
|
@@ -0,0 +1,887 @@
|
|
|
1
|
+
"""Sync engine — staging, gating, and atomic promotion of the generated tree.
|
|
2
|
+
|
|
3
|
+
Owns stage/check/swap for ``parse sync``: build the COMPLETE next package
|
|
4
|
+
beside the live one, self-test it, then promote with an atomic directory
|
|
5
|
+
swap. The CLI layer (``parse_sdk.cli``) keeps fetch/render/guard/install and
|
|
6
|
+
calls :func:`stage_check_swap` with rendered artifacts + resolved paths.
|
|
7
|
+
User-facing output stays ``click.echo`` (CLI-layer output, hosted here
|
|
8
|
+
mechanically — the extraction moved code, not behavior).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import secrets
|
|
17
|
+
import shutil
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Dict, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
import click
|
|
25
|
+
|
|
26
|
+
from parse_sdk import __version__, checks, docgen, scaffold
|
|
27
|
+
from parse_sdk._sanitize import CONTROL_BYTES as _CONSOLE_UNSAFE
|
|
28
|
+
from parse_sdk.codegen_v2 import resolve_root_name
|
|
29
|
+
from parse_sdk.scaffold import is_reserved_slug, safe_slug
|
|
30
|
+
|
|
31
|
+
MANIFEST_NAME = "_manifest.json"
|
|
32
|
+
|
|
33
|
+
# C0 controls (minus \n and \t) + DEL + the C1 controls (\x80-\x9f, which
|
|
34
|
+
# include the single-byte CSI \x9b some terminals honor as an escape
|
|
35
|
+
# introducer). ``click.echo`` strips ANSI only when the stream is not a TTY,
|
|
36
|
+
# and a bare ``\r`` survives even piped — so a server-controlled string
|
|
37
|
+
# (slug/name/endpoint/error text) could rewrite or hide earlier terminal
|
|
38
|
+
# output. Server strings pass through ``_safe_console`` before any echo;
|
|
39
|
+
# locally-derived values (assigned slugs, paths) don't need it. None of the
|
|
40
|
+
# stripped codepoints has a legitimate role in a slug/name/endpoint. The class
|
|
41
|
+
# is single-sourced in ``parse_sdk._sanitize`` (imported as ``_CONSOLE_UNSAFE``),
|
|
42
|
+
# shared with ``_runtime``'s excerpt sanitizer so the two cannot drift.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _safe_console(s: object) -> str:
|
|
46
|
+
"""Strip terminal-control characters from a server-supplied string."""
|
|
47
|
+
return _CONSOLE_UNSAFE.sub("", str(s))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _load_manifest(target_root: Path) -> dict:
|
|
51
|
+
"""Read parse_apis/_manifest.json ({uuid: slug}); {} if absent/malformed.
|
|
52
|
+
|
|
53
|
+
The malformed→{} contract holds PER ENTRY too: a non-string key or slug
|
|
54
|
+
(hand-edited / corrupted manifest) would otherwise flow into
|
|
55
|
+
``safe_slug``/path joins and crash sync with a raw TypeError."""
|
|
56
|
+
p = target_root / MANIFEST_NAME
|
|
57
|
+
if not p.exists():
|
|
58
|
+
return {}
|
|
59
|
+
try:
|
|
60
|
+
data = json.loads(p.read_text())
|
|
61
|
+
except (json.JSONDecodeError, OSError):
|
|
62
|
+
return {}
|
|
63
|
+
if not isinstance(data, dict):
|
|
64
|
+
return {}
|
|
65
|
+
return {k: v for k, v in data.items() if isinstance(k, str) and isinstance(v, str)}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _assign_slugs(schemas: List[dict], prior: dict) -> dict:
|
|
69
|
+
"""Return {uuid: assigned_slug} for the schemas to generate.
|
|
70
|
+
|
|
71
|
+
Pin by UUID: a uuid seen in the prior manifest keeps its slug (if that
|
|
72
|
+
slug is still usable this run), neutralizing the server's updated_at-ordered
|
|
73
|
+
flip. A candidate that collides with an already-claimed slug, or that takes
|
|
74
|
+
a package-reserved name, gets a deterministic `_<uuid8>` suffix (never
|
|
75
|
+
order-based `_2`). The one-time first-seen bare-vs-suffixed choice follows
|
|
76
|
+
the server's response order; stable thereafter.
|
|
77
|
+
|
|
78
|
+
A slug is usable only if it is (a) not reserved (``scaffold.is_reserved_slug``
|
|
79
|
+
— a name that as a directory under ``src/parse_apis/`` would shadow a
|
|
80
|
+
package-level scaffold/generated member) and (b) not already claimed by a
|
|
81
|
+
different uuid *case-insensitively* (two slugs differing only in
|
|
82
|
+
case must not collapse into one directory on a case-insensitive filesystem
|
|
83
|
+
like macOS/APFS). Both rules apply to the prior-manifest pin too (audit
|
|
84
|
+
B2-residual), so a reserved/colliding name can never be reintroduced via the
|
|
85
|
+
pin path.
|
|
86
|
+
"""
|
|
87
|
+
claimed_fold: dict = {} # casefolded slug -> uuid (this run)
|
|
88
|
+
assigned: dict = {} # uuid -> slug
|
|
89
|
+
|
|
90
|
+
def _usable(slug: str, uuid: str) -> bool:
|
|
91
|
+
if is_reserved_slug(slug):
|
|
92
|
+
return False
|
|
93
|
+
owner = claimed_fold.get(slug.casefold())
|
|
94
|
+
return owner is None or owner == uuid
|
|
95
|
+
|
|
96
|
+
for s in schemas:
|
|
97
|
+
uuid = s["id"]
|
|
98
|
+
# Sanitize first: a server slug is used as a directory name AND an import
|
|
99
|
+
# path, so a hostile value must be made identifier-safe before it can
|
|
100
|
+
# reach any filesystem op (audit SEC-1). Prior pins are sanitized too, so
|
|
101
|
+
# an unsafe name written by an older engine is never honored verbatim.
|
|
102
|
+
candidate = safe_slug(s["slug"])
|
|
103
|
+
prior_slug = prior.get(uuid)
|
|
104
|
+
if prior_slug is not None:
|
|
105
|
+
prior_slug = safe_slug(prior_slug)
|
|
106
|
+
if prior_slug and _usable(prior_slug, uuid):
|
|
107
|
+
slug = prior_slug
|
|
108
|
+
elif _usable(candidate, uuid):
|
|
109
|
+
slug = candidate
|
|
110
|
+
else:
|
|
111
|
+
# SEC-1: the uuid suffix becomes part of a directory name AND import
|
|
112
|
+
# path, so it must be sanitized exactly like the slug — a hostile
|
|
113
|
+
# server `id` (e.g. "../../x") must not reintroduce the path
|
|
114
|
+
# separators that `safe_slug(s["slug"])` already stripped from the
|
|
115
|
+
# candidate.
|
|
116
|
+
uuid_suffix = safe_slug(uuid)[:8]
|
|
117
|
+
slug = f"{candidate}_{uuid_suffix}"
|
|
118
|
+
n = 1
|
|
119
|
+
while not _usable(slug, uuid):
|
|
120
|
+
slug = f"{candidate}_{uuid_suffix}_{n}"
|
|
121
|
+
n += 1
|
|
122
|
+
claimed_fold[slug.casefold()] = uuid
|
|
123
|
+
assigned[uuid] = slug
|
|
124
|
+
return assigned
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _carry_skipped(prior: dict, server_uuids: set, assigned: dict) -> dict:
|
|
128
|
+
"""Pins to carry into the new manifest for scrapers still in the server
|
|
129
|
+
response but SKIPPED this sync (un-modeled → not in `assigned`). Keep a pin
|
|
130
|
+
only if its slug doesn't collide with a rendered uuid's slug (rendered
|
|
131
|
+
wins; the skipped one re-derives next sync). This preserves the dir↔uuid
|
|
132
|
+
mapping across a transient un-modeling, so re-modeling doesn't flip a
|
|
133
|
+
shared-base slug — and guarantees no two uuids ever map to one slug.
|
|
134
|
+
|
|
135
|
+
Carried pins are ``safe_slug``-sanitized exactly like ``_assign_slugs``'
|
|
136
|
+
pin path: carried values flow into ``copytree``/``rmtree`` target paths, so
|
|
137
|
+
an unsafe name written by an older engine must be unrepresentable here too
|
|
138
|
+
— not merely unreachable given the current writer. (A sanitized pin that no
|
|
139
|
+
longer matches its on-disk dir simply isn't copied; the slug re-derives on
|
|
140
|
+
the next modeled sync.)
|
|
141
|
+
"""
|
|
142
|
+
assigned_slugs = set(assigned.values())
|
|
143
|
+
return {
|
|
144
|
+
u: safe_slug(prior[u])
|
|
145
|
+
for u in prior
|
|
146
|
+
if u in server_uuids and u not in assigned
|
|
147
|
+
and safe_slug(prior[u]) not in assigned_slugs
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# Schema-shape accessors — total over malformed nested shapes (a hostile/buggy
|
|
152
|
+
# server can deliver a non-dict ``resources`` or a non-list ``endpoints``;
|
|
153
|
+
# ``_valid_schema_row`` gates only id/slug). Single-sourced here and re-exported
|
|
154
|
+
# through ``cli`` so the ``parse list`` view and the sync success line never
|
|
155
|
+
# drift in how they read a schema's resources/endpoints.
|
|
156
|
+
def _schema_resources(schema: dict) -> dict:
|
|
157
|
+
resources = schema.get("resources")
|
|
158
|
+
return resources if isinstance(resources, dict) else {}
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _schema_modeled(schema: dict) -> bool:
|
|
162
|
+
return bool(_schema_resources(schema))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _schema_endpoint_names(schema: dict) -> List[str]:
|
|
166
|
+
endpoints = schema.get("endpoints")
|
|
167
|
+
if not isinstance(endpoints, list):
|
|
168
|
+
return []
|
|
169
|
+
names: List[str] = []
|
|
170
|
+
for endpoint in endpoints:
|
|
171
|
+
if not isinstance(endpoint, dict):
|
|
172
|
+
continue
|
|
173
|
+
name = endpoint.get("name")
|
|
174
|
+
if isinstance(name, str):
|
|
175
|
+
names.append(name)
|
|
176
|
+
return names
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _schema_resource_names(schema: dict) -> List[str]:
|
|
180
|
+
return sorted(str(name) for name in _schema_resources(schema).keys())
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _sweep_stale_stages(src_parent: Path) -> None:
|
|
184
|
+
"""Best-effort removal of staging dirs left by CRASHED runs (>1 day old).
|
|
185
|
+
Never matches a concurrent run's fresh stage (per-run unique names), and
|
|
186
|
+
deliberately leaves ``.parse_apis.old-*`` alone — after a failed restore
|
|
187
|
+
that dir can hold the user's only live copy."""
|
|
188
|
+
for stale in src_parent.glob(".parse_apis.next-*"):
|
|
189
|
+
try:
|
|
190
|
+
if time.time() - stale.stat().st_mtime > 86400:
|
|
191
|
+
shutil.rmtree(stale, ignore_errors=True)
|
|
192
|
+
except OSError:
|
|
193
|
+
pass
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _atomic_write_text(path: Path, text: str) -> None:
|
|
197
|
+
"""Write ``text`` to ``path`` atomically: a fresh temp file in the same
|
|
198
|
+
directory + ``os.replace``, so an interrupted write (Ctrl-C, OOM, shutdown)
|
|
199
|
+
can't leave a half-written, unparseable file — important for the project's
|
|
200
|
+
critical build config."""
|
|
201
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
|
|
202
|
+
try:
|
|
203
|
+
os.write(fd, text.encode("utf-8"))
|
|
204
|
+
os.close(fd)
|
|
205
|
+
os.replace(tmp, path)
|
|
206
|
+
except BaseException:
|
|
207
|
+
try:
|
|
208
|
+
os.close(fd)
|
|
209
|
+
except OSError:
|
|
210
|
+
pass
|
|
211
|
+
try:
|
|
212
|
+
os.unlink(tmp)
|
|
213
|
+
except OSError:
|
|
214
|
+
pass
|
|
215
|
+
raise
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _staged_check_failures(staging: Path, *, recursive: bool = True) -> List[str]:
|
|
219
|
+
"""Run the staged-artifact self-tests; return human-readable failure lines
|
|
220
|
+
(empty == clean). Promotion is gated on this being empty.
|
|
221
|
+
|
|
222
|
+
``recursive=False`` scans only the entries directly in ``staging`` (the
|
|
223
|
+
top-level artifacts), used by the whole-tree pre-promotion pass: every slug
|
|
224
|
+
dir was already gated individually in the per-slug quarantine loop, so the
|
|
225
|
+
whole-tree pass need only check the non-slug artifacts written after it."""
|
|
226
|
+
lines: List[str] = []
|
|
227
|
+
# ONE pass over the staged tree: read each file once, parse each .py once,
|
|
228
|
+
# run all four checks off the cached text/AST (checks.scan_tree). The syntax
|
|
229
|
+
# gate (compile of every __init__.py) is part of that pass — a SyntaxError
|
|
230
|
+
# the import gate's ast.parse silently skips would otherwise promote a
|
|
231
|
+
# broken/injected `__init__.py` into the live tree (see codegen header
|
|
232
|
+
# escaping, SEC). Format the four finding lists in the same order the old
|
|
233
|
+
# sequential gates produced (syntax, secrets, imports, docs).
|
|
234
|
+
found = checks.scan_tree(staging, recursive=recursive)
|
|
235
|
+
for path, err in found.syntax_errors:
|
|
236
|
+
lines.append(f"generated module {path.relative_to(staging)} is not valid Python: {err}")
|
|
237
|
+
for path, family, lineno, masked in found.secrets:
|
|
238
|
+
lines.append(
|
|
239
|
+
f"secret-shape [{family}] in {path.relative_to(staging)}:{lineno} "
|
|
240
|
+
f"({masked})")
|
|
241
|
+
for path, mod in found.forbidden_imports:
|
|
242
|
+
lines.append(f"forbidden import `{mod}` in {path.relative_to(staging)}")
|
|
243
|
+
for path, needle in found.stale_docs:
|
|
244
|
+
lines.append(f"stale import-model doc '{needle}' in {path.relative_to(staging)}")
|
|
245
|
+
return lines
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _example_drop_reason(example_src: str) -> Optional[str]:
|
|
249
|
+
"""Why a stored example must not ship — ``None`` when it is clean.
|
|
250
|
+
|
|
251
|
+
Stored examples are arbitrary user code migrated best-effort; one with a
|
|
252
|
+
stale/aliased ``parse_sdk.<slug>`` import or a leaked credential must drop
|
|
253
|
+
just that example file — never abort the whole sync for every other API.
|
|
254
|
+
Returns the FIRST failing check as a short cause string (secret-shaped
|
|
255
|
+
value / forbidden import / stale import-model doc) so the drop warning can
|
|
256
|
+
name what actually failed instead of hedging all three causes at once.
|
|
257
|
+
"""
|
|
258
|
+
with tempfile.TemporaryDirectory(prefix="parse_ex_") as td:
|
|
259
|
+
d = Path(td)
|
|
260
|
+
(d / "example.py").write_text(example_src)
|
|
261
|
+
# ONE pass over the single example file (the same single-source scanner
|
|
262
|
+
# the staged-tree gate uses), then report the FIRST failing check in the
|
|
263
|
+
# secret → import → doc priority the old sequential calls produced. The
|
|
264
|
+
# syntax finding is intentionally not a drop cause for stored examples.
|
|
265
|
+
found = checks.scan_tree(d)
|
|
266
|
+
if found.secrets:
|
|
267
|
+
_, family, lineno, masked = found.secrets[0]
|
|
268
|
+
return (f"it contains a secret-shaped value [{family}] "
|
|
269
|
+
f"at line {lineno} ({masked})")
|
|
270
|
+
if found.forbidden_imports:
|
|
271
|
+
_, mod = found.forbidden_imports[0]
|
|
272
|
+
return f"it has a forbidden import '{mod}'"
|
|
273
|
+
if found.stale_docs:
|
|
274
|
+
_, needle = found.stale_docs[0]
|
|
275
|
+
return f"it teaches a stale import model ('{needle}')"
|
|
276
|
+
return None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
# Per-slug files sync OWNS (writes every sync, compares for change-detection).
|
|
280
|
+
# Non-owned residue (__pycache__, user files) is neither written nor compared.
|
|
281
|
+
OWNED_SLUG_FILES = frozenset({"__init__.py", "example.py", "README.md"})
|
|
282
|
+
|
|
283
|
+
# The volatile render-stamp line in a generated module's header — re-rendered
|
|
284
|
+
# with a fresh wall-clock on EVERY sync, so it must be excluded from the
|
|
285
|
+
# changed/unchanged comparison or every kept slug reads [changed] forever and
|
|
286
|
+
# the warning gating that keys on `unchanged` never fires.
|
|
287
|
+
_VOLATILE_HEADER_RE = re.compile(r"^Generated at: .*$", re.MULTILINE)
|
|
288
|
+
|
|
289
|
+
# The scaffold's engine pin. Name-boundary anchored (a foreign dep named
|
|
290
|
+
# `acme-parse-sdk` must not match) and whitespace-tolerant around `==`
|
|
291
|
+
# (PEP 508 allows it). Always located via ``_find_pin`` — never a bare
|
|
292
|
+
# ``search`` — so a TOML comment mentioning the pin can neither be restamped
|
|
293
|
+
# nor satisfy the pin-presence check. Shared by the post-swap restamp and
|
|
294
|
+
# `--check`'s missing-pin finding.
|
|
295
|
+
_PIN_RE = re.compile(r"(?<![A-Za-z0-9._-])parse-sdk\s*==\s*([0-9A-Za-z.+!\-]+)")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _find_pin(text: str) -> "Optional[re.Match[str]]":
|
|
299
|
+
"""The first ``parse-sdk==X`` match on a NON-comment line, or ``None``.
|
|
300
|
+
|
|
301
|
+
The resolver never reads comments, so a ``# parse-sdk==0.2.0`` line is
|
|
302
|
+
neither a pin to restamp (rewriting it would leave the real dep stale
|
|
303
|
+
while the advisory claims it moved) nor evidence a pin exists.
|
|
304
|
+
"""
|
|
305
|
+
for m in _PIN_RE.finditer(text):
|
|
306
|
+
line_start = text.rfind("\n", 0, m.start()) + 1
|
|
307
|
+
if text[line_start:m.start()].lstrip().startswith("#"):
|
|
308
|
+
continue
|
|
309
|
+
return m
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _slug_dir_differs(live_dir: Path, staged_dir: Path) -> bool:
|
|
314
|
+
"""Whether a kept slug's sync-owned files changed between the live package
|
|
315
|
+
and the staged next package (pre-swap, same filesystem).
|
|
316
|
+
|
|
317
|
+
Compares the FULL owned-file set — ``OWNED_SLUG_FILES`` — because a
|
|
318
|
+
usage-guide / endpoint-description enrichment changes only README.md or
|
|
319
|
+
example.py while the emitted module stays byte-identical; a presence flip
|
|
320
|
+
(example dropped or newly shipped) is a change too. The module's volatile
|
|
321
|
+
``Generated at:`` stamp is normalized out (it differs on every render by
|
|
322
|
+
construction); ``__generated_with__`` is deliberately compared — an engine
|
|
323
|
+
bump IS a change. A non-UTF-8 artifact falls back to a raw byte compare.
|
|
324
|
+
"""
|
|
325
|
+
for fn in sorted(OWNED_SLUG_FILES):
|
|
326
|
+
live, staged = live_dir / fn, staged_dir / fn
|
|
327
|
+
if live.exists() != staged.exists():
|
|
328
|
+
return True
|
|
329
|
+
if not live.exists():
|
|
330
|
+
continue
|
|
331
|
+
live_b, staged_b = live.read_bytes(), staged.read_bytes()
|
|
332
|
+
if fn == "__init__.py":
|
|
333
|
+
try:
|
|
334
|
+
live_t = _VOLATILE_HEADER_RE.sub("", live_b.decode("utf-8"))
|
|
335
|
+
staged_t = _VOLATILE_HEADER_RE.sub("", staged_b.decode("utf-8"))
|
|
336
|
+
except UnicodeDecodeError:
|
|
337
|
+
if live_b != staged_b:
|
|
338
|
+
return True
|
|
339
|
+
continue
|
|
340
|
+
if live_t != staged_t:
|
|
341
|
+
return True
|
|
342
|
+
elif live_b != staged_b:
|
|
343
|
+
return True
|
|
344
|
+
return False
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _restamp_scaffold_pin(root: Path, *, as_json: bool) -> None:
|
|
348
|
+
"""Restamp ONLY the scaffold's exact ``parse-sdk==X`` pin to THIS engine —
|
|
349
|
+
never the whole file: the scaffold pyproject is user-editable (added
|
|
350
|
+
dev-deps, tool sections), and a full re-render silently reverted those edits
|
|
351
|
+
on every engine bump. The pin must track the engine (generated code
|
|
352
|
+
subclasses underscore-private runtime bases; the pin is the only thing
|
|
353
|
+
keeping ``uv sync`` from pulling a runtime the on-disk code was never
|
|
354
|
+
generated for). Atomic write; the restamp advisory prints only when the pin
|
|
355
|
+
moved — the missing-pin advisory repeats every sync by design. ``_find_pin``
|
|
356
|
+
skips comment lines and foreign deps, and the splice rewrites exactly the
|
|
357
|
+
matched span, so nothing else in the user-editable file can move."""
|
|
358
|
+
pin_path = root / "pyproject.toml"
|
|
359
|
+
try:
|
|
360
|
+
prior_pin_text = pin_path.read_text()
|
|
361
|
+
except OSError:
|
|
362
|
+
prior_pin_text = ""
|
|
363
|
+
m = _find_pin(prior_pin_text)
|
|
364
|
+
if m is None:
|
|
365
|
+
# Hand-mangled (or missing) file: rewriting it would clobber user
|
|
366
|
+
# content on a guess. Advise and continue — an absent pin lets the
|
|
367
|
+
# resolver drift silently, so CI's `parse sync --check` treats it as a
|
|
368
|
+
# finding.
|
|
369
|
+
if not as_json:
|
|
370
|
+
click.echo(click.style(
|
|
371
|
+
f" · {pin_path} has no parse-sdk pin — expected "
|
|
372
|
+
f"`parse-sdk=={__version__}`; add it so `uv sync` installs the "
|
|
373
|
+
f"runtime this code was generated for", fg="yellow"))
|
|
374
|
+
elif m.group(1) != __version__:
|
|
375
|
+
_atomic_write_text(
|
|
376
|
+
pin_path,
|
|
377
|
+
prior_pin_text[:m.start()] + f"parse-sdk=={__version__}"
|
|
378
|
+
+ prior_pin_text[m.end():])
|
|
379
|
+
if not as_json:
|
|
380
|
+
click.echo(click.style(
|
|
381
|
+
f" · engine moved {m.group(1)} → {__version__}; scaffold pin "
|
|
382
|
+
f"restamped — run `uv sync` to align the installed runtime",
|
|
383
|
+
fg="yellow"))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _quarantine_staged_slugs(
|
|
387
|
+
next_pkg: Path, pkg: Path, prior: Dict[str, str], new_manifest: Dict[str, str]
|
|
388
|
+
) -> List[dict]:
|
|
389
|
+
"""Per-slug quarantine (carry-and-re-gate). A module finding must not abort
|
|
390
|
+
the whole sync: at fleet scale a single flagged spec would make promotion
|
|
391
|
+
permanently impossible for every healthy API. For a flagged slug, fall back
|
|
392
|
+
to its prior LIVE copy and RE-GATE that copy — nothing flagged is ever
|
|
393
|
+
promoted; a prior copy that fails the current gates ships absent. The slug's
|
|
394
|
+
manifest pin stays either way (the spec still renders; only its artifacts are
|
|
395
|
+
withheld), so the dir↔uuid mapping survives until the spec passes. Returns
|
|
396
|
+
one record per flagged slug: ``{slug, uuid, retained, failures}``."""
|
|
397
|
+
slug_to_uuid = {v: k for k, v in new_manifest.items()}
|
|
398
|
+
quarantined: List[dict] = []
|
|
399
|
+
for slug_dir in sorted(p for p in next_pkg.iterdir() if p.is_dir()):
|
|
400
|
+
slug = slug_dir.name
|
|
401
|
+
slug_failures = _staged_check_failures(slug_dir)
|
|
402
|
+
if not slug_failures:
|
|
403
|
+
continue
|
|
404
|
+
uuid = slug_to_uuid.get(slug)
|
|
405
|
+
# Re-sanitize the prior manifest slug before using it as a path
|
|
406
|
+
# component: a tampered/legacy manifest value (e.g. '../../etc') would
|
|
407
|
+
# otherwise make `pkg / prior_slug` escape the live tree and `copytree`
|
|
408
|
+
# read outside the package (audit SEC-1). safe_slug is idempotent on
|
|
409
|
+
# canonical slugs, so a legitimate prior dir still resolves; a traversal
|
|
410
|
+
# value is neutralized.
|
|
411
|
+
_prior_raw = prior.get(uuid) if uuid else None
|
|
412
|
+
prior_slug = safe_slug(_prior_raw) if _prior_raw else None
|
|
413
|
+
live_dir = (pkg / prior_slug) if prior_slug else None
|
|
414
|
+
shutil.rmtree(slug_dir, ignore_errors=True)
|
|
415
|
+
retained = False
|
|
416
|
+
if live_dir is not None and live_dir.is_dir():
|
|
417
|
+
try:
|
|
418
|
+
shutil.copytree(live_dir, slug_dir)
|
|
419
|
+
except FileNotFoundError:
|
|
420
|
+
pass # concurrent promote moved the live tree; no prior copy
|
|
421
|
+
if slug_dir.is_dir():
|
|
422
|
+
if _staged_check_failures(slug_dir):
|
|
423
|
+
# Fail-closed: the prior copy trips the CURRENT gates too
|
|
424
|
+
# (e.g. an engine-side tightening) — never promote flagged
|
|
425
|
+
# content, not even previously-live content.
|
|
426
|
+
shutil.rmtree(slug_dir, ignore_errors=True)
|
|
427
|
+
else:
|
|
428
|
+
retained = True
|
|
429
|
+
quarantined.append(
|
|
430
|
+
{"slug": slug, "uuid": uuid, "retained": retained,
|
|
431
|
+
"failures": slug_failures})
|
|
432
|
+
return quarantined
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def stage_check_swap(
|
|
436
|
+
cfg,
|
|
437
|
+
schemas: List[dict],
|
|
438
|
+
rendered: List[tuple],
|
|
439
|
+
skipped: List[dict],
|
|
440
|
+
*,
|
|
441
|
+
project: Path,
|
|
442
|
+
root: Path,
|
|
443
|
+
pkg: Path,
|
|
444
|
+
clean: bool,
|
|
445
|
+
check: bool = False,
|
|
446
|
+
as_json: bool = False,
|
|
447
|
+
) -> None:
|
|
448
|
+
"""Stage the next package, gate it, promote it atomically, and report.
|
|
449
|
+
|
|
450
|
+
Atomicity: the live package is never mutated in
|
|
451
|
+
place. The next package — committed scaffold + freshly generated slugs +
|
|
452
|
+
retained present-but-skipped slugs — is built on the same filesystem and
|
|
453
|
+
swapped via two ``os.replace`` calls with rollback, so a failure (mid-build,
|
|
454
|
+
a failed check, or an interrupted swap) leaves the live tree exactly as it
|
|
455
|
+
was. Orphaned slugs (uuids dropped from the server) are simply not carried
|
|
456
|
+
into the next package — that IS the prune. ``clean=True`` drops
|
|
457
|
+
the carried slugs too, rebuilding the payload from scratch — and, unlike the
|
|
458
|
+
old upfront ``rmtree``, only after the new payload is proven good.
|
|
459
|
+
"""
|
|
460
|
+
prior = _load_manifest(pkg)
|
|
461
|
+
server_uuids = {s["id"] for s in schemas}
|
|
462
|
+
assigned = _assign_slugs([s for s, _ in rendered], prior)
|
|
463
|
+
rendered_by_uuid = {s["id"]: src for s, src in rendered}
|
|
464
|
+
generated = [{**s, "_assigned_slug": assigned[s["id"]]} for s, _ in rendered]
|
|
465
|
+
generated_slugs = {s["_assigned_slug"] for s in generated}
|
|
466
|
+
root_by_uuid = {s["id"]: resolve_root_name(s) for s in generated}
|
|
467
|
+
|
|
468
|
+
# carried = present-but-skipped slugs to retain; --clean drops them.
|
|
469
|
+
carried = {} if clean else _carry_skipped(prior, server_uuids, assigned)
|
|
470
|
+
new_manifest = {**carried, **assigned}
|
|
471
|
+
|
|
472
|
+
# Build the complete next package beside the live one (same filesystem → the
|
|
473
|
+
# promote is an atomic rename). Per-run UNIQUE names: two concurrent syncs
|
|
474
|
+
# each stage privately and the promote stays last-writer-wins atomic (the
|
|
475
|
+
# old fixed names + upfront rmtree let one run delete the other's
|
|
476
|
+
# half-built stage).
|
|
477
|
+
src_parent = pkg.parent
|
|
478
|
+
nonce = f"{os.getpid():x}-{secrets.token_hex(4)}"
|
|
479
|
+
next_pkg = src_parent / f".parse_apis.next-{nonce}"
|
|
480
|
+
old_pkg = src_parent / f".parse_apis.old-{nonce}"
|
|
481
|
+
_sweep_stale_stages(src_parent)
|
|
482
|
+
next_pkg.mkdir(parents=True)
|
|
483
|
+
coverage_by_uuid: Dict[str, List[Tuple[str, str]]] = {}
|
|
484
|
+
try:
|
|
485
|
+
# 1. freshly generated slugs — rendered, with per-example isolation.
|
|
486
|
+
example_shipped: Dict[str, bool] = {}
|
|
487
|
+
for s in generated:
|
|
488
|
+
slug = s["_assigned_slug"]
|
|
489
|
+
slug_dir = next_pkg / slug
|
|
490
|
+
slug_dir.mkdir(parents=True, exist_ok=True)
|
|
491
|
+
(slug_dir / "__init__.py").write_text(rendered_by_uuid[s["id"]])
|
|
492
|
+
# Render the example FIRST: its shippability drives the README/index
|
|
493
|
+
# example links. `render_example` is the single chokepoint that binds
|
|
494
|
+
# the self-import to the ASSIGNED slug (the sanitized/de-duped dir) and
|
|
495
|
+
# drops an unbindable/unparseable one; pass the RAW schema `s` so its
|
|
496
|
+
# root anchor matches the emitted module (rendered from `s`), and the
|
|
497
|
+
# assigned slug as the import-path target.
|
|
498
|
+
ex = docgen.render_example(s, slug=slug)
|
|
499
|
+
drop_reason = (_example_drop_reason(ex) if ex is not None else
|
|
500
|
+
"its import couldn't be bound to the generated client")
|
|
501
|
+
shipped = ex is not None and drop_reason is None
|
|
502
|
+
if shipped:
|
|
503
|
+
(slug_dir / "example.py").write_text(ex)
|
|
504
|
+
elif (s.get("sdk_usage_example") or "").strip() and not as_json:
|
|
505
|
+
click.echo(click.style(
|
|
506
|
+
f" ⚠ dropped {slug}/example.py — {_safe_console(drop_reason)} — "
|
|
507
|
+
f"fix the example for this API in Parse, then `parse sync`; the "
|
|
508
|
+
f"client module is unaffected", fg="yellow", dim=True))
|
|
509
|
+
example_shipped[s["id"]] = shipped
|
|
510
|
+
(slug_dir / "README.md").write_text(
|
|
511
|
+
docgen.render_readme({**s, "slug": slug}, has_example=shipped))
|
|
512
|
+
coverage_by_uuid[s["id"]] = list(docgen.coverage_warnings(s))
|
|
513
|
+
# 2. retained present-but-skipped slugs (copied from the live package).
|
|
514
|
+
for slug in set(new_manifest.values()) - generated_slugs:
|
|
515
|
+
live_dir = pkg / slug
|
|
516
|
+
try:
|
|
517
|
+
if live_dir.is_dir():
|
|
518
|
+
shutil.copytree(live_dir, next_pkg / slug)
|
|
519
|
+
except FileNotFoundError:
|
|
520
|
+
# A concurrent sync swapped the live tree away mid-copy. That
|
|
521
|
+
# run owns the retained slugs now; if the race persists, the
|
|
522
|
+
# promote below surfaces it cleanly instead of a raw traceback.
|
|
523
|
+
continue
|
|
524
|
+
# 3. Per-slug quarantine — flagged slugs fall back to a re-gated prior
|
|
525
|
+
# copy; nothing flagged is ever promoted (see _quarantine_staged_slugs).
|
|
526
|
+
quarantined = _quarantine_staged_slugs(next_pkg, pkg, prior, new_manifest)
|
|
527
|
+
quarantined_uuids = {q["uuid"] for q in quarantined if q["uuid"]}
|
|
528
|
+
|
|
529
|
+
# 4. manifest + cross-API index (from the post-quarantine survivor
|
|
530
|
+
# set: a retained prior copy keeps its entry; a removed module loses
|
|
531
|
+
# it — the index must never teach an import that would fail).
|
|
532
|
+
(next_pkg / MANIFEST_NAME).write_text(json.dumps(new_manifest, indent=2, sort_keys=True))
|
|
533
|
+
index_schemas = []
|
|
534
|
+
for s in generated:
|
|
535
|
+
slug = s["_assigned_slug"]
|
|
536
|
+
if not (next_pkg / slug).is_dir():
|
|
537
|
+
continue
|
|
538
|
+
has_ex = (example_shipped[s["id"]]
|
|
539
|
+
if s["id"] not in quarantined_uuids
|
|
540
|
+
else (next_pkg / slug / "example.py").exists())
|
|
541
|
+
index_schemas.append({**s, "slug": slug, "_has_example": has_ex})
|
|
542
|
+
if index_schemas:
|
|
543
|
+
# render_agents_index and render_claude_index produce byte-identical
|
|
544
|
+
# output (pinned by test_docgen.py); render once and write both to
|
|
545
|
+
# avoid a redundant second render and any drift between the files.
|
|
546
|
+
index_text = docgen.render_agents_index(index_schemas, base_url=cfg.base_url)
|
|
547
|
+
(next_pkg / "AGENTS.md").write_text(index_text)
|
|
548
|
+
(next_pkg / "CLAUDE.md").write_text(index_text)
|
|
549
|
+
|
|
550
|
+
# Self-test the next package's TOP-LEVEL artifacts before it touches the
|
|
551
|
+
# live tree. Slug dirs already passed (or were quarantined) above, so a
|
|
552
|
+
# finding here can only be in a NON-slug artifact written since — exactly
|
|
553
|
+
# _manifest.json / AGENTS.md / CLAUDE.md (the scaffold __init__.py /
|
|
554
|
+
# py.typed are written below, AFTER this check). Those have no per-slug
|
|
555
|
+
# fallback and still abort the whole sync. ``recursive=False`` so this
|
|
556
|
+
# pass does not re-read+re-parse every slug file the per-slug loop just
|
|
557
|
+
# gated (the dominant cost of a fleet sync). Under --check the failures
|
|
558
|
+
# join the findings report instead (nothing was going to be promoted
|
|
559
|
+
# anyway).
|
|
560
|
+
failures = _staged_check_failures(next_pkg, recursive=False)
|
|
561
|
+
if failures and not check:
|
|
562
|
+
click.echo(click.style(
|
|
563
|
+
"✗ sync aborted — staged artifacts failed pre-promotion checks:",
|
|
564
|
+
fg="red"), err=True)
|
|
565
|
+
for line in failures:
|
|
566
|
+
click.echo(click.style(
|
|
567
|
+
f" · {_safe_console(line)}", fg="red"), err=True)
|
|
568
|
+
click.echo(click.style(
|
|
569
|
+
" Your existing generated payload was left unchanged.",
|
|
570
|
+
fg="yellow"), err=True)
|
|
571
|
+
shutil.rmtree(next_pkg, ignore_errors=True)
|
|
572
|
+
if as_json:
|
|
573
|
+
# The full-abort contract: failures alone on stdout (the
|
|
574
|
+
# human framing above went to stderr).
|
|
575
|
+
click.echo(json.dumps({"failures": failures}, indent=2))
|
|
576
|
+
sys.exit(1)
|
|
577
|
+
|
|
578
|
+
# 5. checks passed — add the committed scaffold from its SOURCE OF
|
|
579
|
+
# TRUTH (scaffold.render_*), not by copying the live tree: the copy
|
|
580
|
+
# was a check-then-copy race against a concurrent sync's promote and
|
|
581
|
+
# it propagated a stale scaffold forever. Trusted, so EXCLUDED from
|
|
582
|
+
# the gates above (re-scanning committed files would false-positive,
|
|
583
|
+
# e.g. the `__all__` error names tripping the entropy rule).
|
|
584
|
+
(next_pkg / "__init__.py").write_text(scaffold.render_scaffold_init())
|
|
585
|
+
(next_pkg / "py.typed").write_text("")
|
|
586
|
+
|
|
587
|
+
# 6. Change-visibility: per-uuid status, computed PRE-SWAP while live
|
|
588
|
+
# and staged trees coexist. `removed` is anything live before this sync
|
|
589
|
+
# that the next manifest drops (server orphans; with --clean, the
|
|
590
|
+
# un-carried skipped slugs too). Quarantined uuids get quarantine
|
|
591
|
+
# lines, not status lines.
|
|
592
|
+
status_by_uuid: Dict[str, str] = {}
|
|
593
|
+
for uuid in assigned:
|
|
594
|
+
if uuid in quarantined_uuids:
|
|
595
|
+
continue
|
|
596
|
+
if uuid not in prior:
|
|
597
|
+
status_by_uuid[uuid] = "new"
|
|
598
|
+
elif prior[uuid] != assigned[uuid]:
|
|
599
|
+
status_by_uuid[uuid] = f"renamed from {prior[uuid]}"
|
|
600
|
+
elif _slug_dir_differs(pkg / assigned[uuid], next_pkg / assigned[uuid]):
|
|
601
|
+
status_by_uuid[uuid] = "changed"
|
|
602
|
+
else:
|
|
603
|
+
status_by_uuid[uuid] = "unchanged"
|
|
604
|
+
removed_slugs = sorted(
|
|
605
|
+
prior[u] for u in prior.keys() - new_manifest.keys()
|
|
606
|
+
)
|
|
607
|
+
# The machine-readable result struct (the `--json` contract; the
|
|
608
|
+
# human renderer reads the same underlying maps). Warnings are plain
|
|
609
|
+
# strings at this layer.
|
|
610
|
+
result = {
|
|
611
|
+
"written": [
|
|
612
|
+
{"slug": s["_assigned_slug"], "id": s["id"],
|
|
613
|
+
"root": root_by_uuid[s["id"]],
|
|
614
|
+
"status": status_by_uuid.get(s["id"], "new"),
|
|
615
|
+
"warnings": [{"severity": sev, "text": t}
|
|
616
|
+
for sev, t in (coverage_by_uuid.get(s["id"]) or [])]}
|
|
617
|
+
for s in generated if s["id"] not in quarantined_uuids
|
|
618
|
+
],
|
|
619
|
+
"skipped": [
|
|
620
|
+
{"slug": s.get("slug"), "id": s.get("id"),
|
|
621
|
+
"reason": s.get("_skip_reason") or "un-modeled (no `resources` in spec)"}
|
|
622
|
+
for s in skipped
|
|
623
|
+
],
|
|
624
|
+
"removed": removed_slugs,
|
|
625
|
+
"quarantined": [
|
|
626
|
+
{"slug": q["slug"], "id": q["uuid"], "retained": q["retained"],
|
|
627
|
+
"findings": q["failures"]}
|
|
628
|
+
for q in quarantined
|
|
629
|
+
],
|
|
630
|
+
"failures": list(failures),
|
|
631
|
+
}
|
|
632
|
+
except SystemExit:
|
|
633
|
+
raise
|
|
634
|
+
except BaseException:
|
|
635
|
+
shutil.rmtree(next_pkg, ignore_errors=True)
|
|
636
|
+
raise
|
|
637
|
+
|
|
638
|
+
if check:
|
|
639
|
+
# Dry run for CI: report what a real sync would do, then discard the
|
|
640
|
+
# staging tree — no promote, no pin restamp, no manifest write, no
|
|
641
|
+
# install. Strictness lives HERE (exit 1 on ANY finding, including
|
|
642
|
+
# would-be-quarantined slugs and a de-pinned scaffold); the real sync
|
|
643
|
+
# stays availability-first (exit 0 once the swap succeeds).
|
|
644
|
+
findings: List[str] = list(failures)
|
|
645
|
+
for q in quarantined:
|
|
646
|
+
for line in q["failures"]:
|
|
647
|
+
findings.append(f"would quarantine {q['slug']}: {line}")
|
|
648
|
+
try:
|
|
649
|
+
pin_text = (root / "pyproject.toml").read_text()
|
|
650
|
+
except OSError:
|
|
651
|
+
pin_text = ""
|
|
652
|
+
if _find_pin(pin_text) is None:
|
|
653
|
+
findings.append(
|
|
654
|
+
f"scaffold pyproject has no parse-sdk pin — expected "
|
|
655
|
+
f"parse-sdk=={__version__}")
|
|
656
|
+
shutil.rmtree(next_pkg, ignore_errors=True)
|
|
657
|
+
if as_json:
|
|
658
|
+
# The check-mode JSON contract: `failures` carries EVERY finding
|
|
659
|
+
# (scaffold-level, would-quarantine, missing pin), so a consumer
|
|
660
|
+
# may key pass/fail off `failures` alone — non-empty ⟺ exit 1.
|
|
661
|
+
# The structured per-slug detail stays in `quarantined`.
|
|
662
|
+
result["failures"] = list(findings)
|
|
663
|
+
click.echo(json.dumps(result, indent=2, sort_keys=True))
|
|
664
|
+
sys.exit(1 if findings else 0)
|
|
665
|
+
for s in generated:
|
|
666
|
+
if s["id"] in quarantined_uuids:
|
|
667
|
+
continue
|
|
668
|
+
status = status_by_uuid.get(s["id"], "new")
|
|
669
|
+
click.echo(f" • {s['_assigned_slug']} [{_safe_console(status)}]")
|
|
670
|
+
for slug in removed_slugs:
|
|
671
|
+
click.echo(f" − {_safe_console(slug)} [removed]")
|
|
672
|
+
for line in findings:
|
|
673
|
+
click.echo(click.style(
|
|
674
|
+
f" ✗ {_safe_console(line)}", fg="red"), err=True)
|
|
675
|
+
if findings:
|
|
676
|
+
click.echo(click.style(
|
|
677
|
+
f"✗ check found {len(findings)} finding"
|
|
678
|
+
f"{'s' if len(findings) != 1 else ''} — a real sync would "
|
|
679
|
+
f"quarantine or abort; fix in Parse first.", fg="red"), err=True)
|
|
680
|
+
sys.exit(1)
|
|
681
|
+
click.echo("✓ check clean — a real sync would promote.")
|
|
682
|
+
return
|
|
683
|
+
|
|
684
|
+
# Atomic swap with rollback: live → old, next → live.
|
|
685
|
+
# - First rename fails → live tree untouched; just drop the staged next_pkg.
|
|
686
|
+
# - Promote fails → restore live from old_pkg.
|
|
687
|
+
# old_pkg is removed ONLY after a fully-confirmed swap. If the restore itself
|
|
688
|
+
# fails, old_pkg still holds the user's ONLY live copy, so it must never be
|
|
689
|
+
# rmtree'd on that path — deleting it would be unrecoverable data loss.
|
|
690
|
+
try:
|
|
691
|
+
os.replace(pkg, old_pkg)
|
|
692
|
+
except FileNotFoundError:
|
|
693
|
+
# The live package moved out from under us — a concurrent `parse sync`
|
|
694
|
+
# promoted first. The live tree is whole (last writer wins by design);
|
|
695
|
+
# surface the race as an actionable message, not a raw traceback.
|
|
696
|
+
shutil.rmtree(next_pkg, ignore_errors=True)
|
|
697
|
+
click.echo(click.style(
|
|
698
|
+
"✗ another `parse sync` promoted while this one was staging — "
|
|
699
|
+
"the live tree is consistent (owned by the other run). "
|
|
700
|
+
"Re-run `parse sync` if you need this run's snapshot.",
|
|
701
|
+
fg="red"), err=True)
|
|
702
|
+
sys.exit(1)
|
|
703
|
+
except BaseException:
|
|
704
|
+
shutil.rmtree(next_pkg, ignore_errors=True)
|
|
705
|
+
raise
|
|
706
|
+
try:
|
|
707
|
+
os.replace(next_pkg, pkg)
|
|
708
|
+
except BaseException:
|
|
709
|
+
os.replace(old_pkg, pkg) # restore — raises (preserving old_pkg) if it can't
|
|
710
|
+
shutil.rmtree(next_pkg, ignore_errors=True)
|
|
711
|
+
raise
|
|
712
|
+
# Swap confirmed — old_pkg and next_pkg are now disposable.
|
|
713
|
+
shutil.rmtree(old_pkg, ignore_errors=True)
|
|
714
|
+
shutil.rmtree(next_pkg, ignore_errors=True)
|
|
715
|
+
|
|
716
|
+
# Restamp the scaffold's engine pin to THIS engine (atomic, pin-span only).
|
|
717
|
+
_restamp_scaffold_pin(root, as_json=as_json)
|
|
718
|
+
|
|
719
|
+
# Refresh the committed top-level agent pointer from its source of truth —
|
|
720
|
+
# the same post-swap moment the pin restamp touches the top-level pyproject.
|
|
721
|
+
# Content-compared so an unchanged sync makes no git diff and no spurious
|
|
722
|
+
# mtime churn. Unreached under --check (it returns before the swap), so a dry
|
|
723
|
+
# run never mutates the pointer.
|
|
724
|
+
pointer = scaffold.render_root_pointer()
|
|
725
|
+
for _name in ("AGENTS.md", "CLAUDE.md"):
|
|
726
|
+
_ptr = root / _name
|
|
727
|
+
try:
|
|
728
|
+
_current = _ptr.read_text()
|
|
729
|
+
except OSError:
|
|
730
|
+
_current = None
|
|
731
|
+
if _current != pointer:
|
|
732
|
+
_atomic_write_text(_ptr, pointer)
|
|
733
|
+
|
|
734
|
+
if as_json:
|
|
735
|
+
# Machine-readable result on stdout; the human report below is
|
|
736
|
+
# suppressed entirely (stderr errors already went out on their own).
|
|
737
|
+
click.echo(json.dumps(result, indent=2, sort_keys=True))
|
|
738
|
+
return
|
|
739
|
+
|
|
740
|
+
_STATUS_COLOR = {"new": "green", "changed": "yellow", "unchanged": None}
|
|
741
|
+
promoted = [s for s in generated if s["id"] not in quarantined_uuids]
|
|
742
|
+
if promoted:
|
|
743
|
+
click.echo(
|
|
744
|
+
f"✓ wrote {len(promoted)} typed module"
|
|
745
|
+
f"{'s' if len(promoted) != 1 else ''} to "
|
|
746
|
+
f"{click.style(str(pkg), fg='cyan')}"
|
|
747
|
+
)
|
|
748
|
+
for s in promoted:
|
|
749
|
+
slug = s["_assigned_slug"]
|
|
750
|
+
root_name = root_by_uuid[s["id"]]
|
|
751
|
+
status = status_by_uuid.get(s["id"], "new")
|
|
752
|
+
color = _STATUS_COLOR.get(status, "yellow") # renamed → yellow
|
|
753
|
+
marker = (
|
|
754
|
+
click.style(f"[{_safe_console(status)}]", fg=color)
|
|
755
|
+
if color else click.style(f"[{_safe_console(status)}]", dim=True)
|
|
756
|
+
)
|
|
757
|
+
click.echo(
|
|
758
|
+
f" • {click.style(slug, fg='cyan')} "
|
|
759
|
+
f"({root_name}, {len(_schema_resources(s))} resources, "
|
|
760
|
+
f"{len(_schema_endpoint_names(s))} endpoints) {marker}"
|
|
761
|
+
)
|
|
762
|
+
for slug in removed_slugs:
|
|
763
|
+
click.echo(f" − {click.style(_safe_console(slug), fg='red')} [removed]")
|
|
764
|
+
|
|
765
|
+
if quarantined:
|
|
766
|
+
for q in quarantined:
|
|
767
|
+
outcome = (
|
|
768
|
+
"prior copy retained (stale until the spec passes)"
|
|
769
|
+
if q["retained"] else
|
|
770
|
+
f"module removed; imports of parse_apis.{q['slug']} will fail "
|
|
771
|
+
f"until it passes"
|
|
772
|
+
)
|
|
773
|
+
click.echo(click.style(
|
|
774
|
+
f" ⚠ {q['slug']} [quarantined — {outcome}]", fg="yellow"))
|
|
775
|
+
for line in q["failures"]:
|
|
776
|
+
click.echo(click.style(
|
|
777
|
+
f" · {_safe_console(line)} — fix this API in Parse, "
|
|
778
|
+
f"then `parse sync`", fg="yellow", dim=True))
|
|
779
|
+
n_ret = sum(1 for q in quarantined if q["retained"])
|
|
780
|
+
n_rem = len(quarantined) - n_ret
|
|
781
|
+
click.echo(click.style(
|
|
782
|
+
f" ⚠ {len(quarantined)} API{'s' if len(quarantined) != 1 else ''} "
|
|
783
|
+
f"quarantined ({n_ret} prior cop{'ies' if n_ret != 1 else 'y'} "
|
|
784
|
+
f"retained, {n_rem} removed) — healthy APIs promoted normally.",
|
|
785
|
+
fg="yellow"))
|
|
786
|
+
|
|
787
|
+
# Coverage warnings, gated on change-visibility: a warning teaches once
|
|
788
|
+
# (when the API is new or its payload changed), then aggregates — an
|
|
789
|
+
# unchanged API re-printing the same ⚠ every sync trains users to ignore
|
|
790
|
+
# the channel. Within the printed set, severity decides the shape:
|
|
791
|
+
# STRUCTURAL warnings (dead code, suppressed factory, prose contradicting
|
|
792
|
+
# the emitted signature) print per-line; ENRICHMENT warnings (missing
|
|
793
|
+
# narrative) collapse to one line per API — at fleet scale dozens of
|
|
794
|
+
# identical "endpoint has no description" advisories drown the two real
|
|
795
|
+
# problems.
|
|
796
|
+
# Quarantined uuids are excluded — their quarantine lines own the channel.
|
|
797
|
+
warned_suppressed = 0
|
|
798
|
+
total_enrichment = 0
|
|
799
|
+
apis_with_enrichment = 0
|
|
800
|
+
for s in promoted:
|
|
801
|
+
warns = coverage_by_uuid.get(s["id"]) or []
|
|
802
|
+
if not warns:
|
|
803
|
+
continue
|
|
804
|
+
if status_by_uuid.get(s["id"]) == "unchanged":
|
|
805
|
+
warned_suppressed += 1
|
|
806
|
+
continue
|
|
807
|
+
structural = [t for sev, t in warns if sev == "structural"]
|
|
808
|
+
enrichment = [t for sev, t in warns if sev == "enrichment"]
|
|
809
|
+
for w in structural:
|
|
810
|
+
click.echo(click.style(f" ⚠ {_safe_console(w)}", fg="yellow"))
|
|
811
|
+
if enrichment:
|
|
812
|
+
total_enrichment += len(enrichment)
|
|
813
|
+
apis_with_enrichment += 1
|
|
814
|
+
click.echo(click.style(
|
|
815
|
+
f" ⚠ {s['_assigned_slug']}: {len(enrichment)} enrichment gap"
|
|
816
|
+
f"{'s' if len(enrichment) != 1 else ''} — README renders "
|
|
817
|
+
f"reference + example until enriched in Parse",
|
|
818
|
+
fg="yellow", dim=True))
|
|
819
|
+
# On an all-new run (init's first sync by construction) add the grand
|
|
820
|
+
# total, so 53 fresh APIs read as one number, not 53 lines.
|
|
821
|
+
if (apis_with_enrichment > 1 and promoted
|
|
822
|
+
and all(status_by_uuid.get(s["id"], "new") == "new" for s in promoted)):
|
|
823
|
+
click.echo(click.style(
|
|
824
|
+
f" ⚠ {total_enrichment} enrichment gap"
|
|
825
|
+
f"{'s' if total_enrichment != 1 else ''} across "
|
|
826
|
+
f"{apis_with_enrichment} APIs — enrich in Parse to upgrade the READMEs",
|
|
827
|
+
fg="yellow", dim=True))
|
|
828
|
+
if warned_suppressed:
|
|
829
|
+
click.echo(click.style(
|
|
830
|
+
f" ⚠ {warned_suppressed} unchanged API"
|
|
831
|
+
f"{'s' if warned_suppressed != 1 else ''} still ha"
|
|
832
|
+
f"{'ve' if warned_suppressed != 1 else 's'} enrichment warnings "
|
|
833
|
+
f"(shown when an API is new or changes)", fg="yellow", dim=True))
|
|
834
|
+
if skipped:
|
|
835
|
+
click.echo()
|
|
836
|
+
click.echo(click.style(
|
|
837
|
+
f"⚠ skipped {len(skipped)} un-modeled API"
|
|
838
|
+
f"{'s' if len(skipped) != 1 else ''} (no `resources` in spec):",
|
|
839
|
+
fg="yellow",
|
|
840
|
+
))
|
|
841
|
+
for s in skipped[:10]:
|
|
842
|
+
click.echo(f" · {_safe_console(s['slug'])} {_safe_console(s['id'])}")
|
|
843
|
+
if len(skipped) > 10:
|
|
844
|
+
click.echo(f" · … and {len(skipped) - 10} more")
|
|
845
|
+
click.echo(click.style(
|
|
846
|
+
" Revise these through Parse so the agent fills in the SDK layer.",
|
|
847
|
+
fg="yellow",
|
|
848
|
+
))
|
|
849
|
+
if promoted:
|
|
850
|
+
click.echo()
|
|
851
|
+
click.echo("Usage:")
|
|
852
|
+
for s in promoted[:3]:
|
|
853
|
+
slug = s["_assigned_slug"]
|
|
854
|
+
click.echo(f" from parse_apis.{slug} import {root_by_uuid[s['id']]}")
|
|
855
|
+
if len(promoted) > 3:
|
|
856
|
+
# Without this a cold agent reading the three example lines
|
|
857
|
+
# concludes the project has three APIs.
|
|
858
|
+
click.echo(
|
|
859
|
+
f" … and {len(promoted) - 3} more — "
|
|
860
|
+
f"see {(pkg / 'CLAUDE.md').relative_to(project)}")
|
|
861
|
+
click.echo()
|
|
862
|
+
click.echo(click.style(
|
|
863
|
+
"`parse_apis` is an installed editable package — imports resolve from any "
|
|
864
|
+
"directory; re-run `parse sync` to refresh.",
|
|
865
|
+
dim=True,
|
|
866
|
+
))
|
|
867
|
+
|
|
868
|
+
# Agent-ready next steps — printed whenever this sync introduced at least
|
|
869
|
+
# one NEW API (init's first sync is all-new by construction), absent on an
|
|
870
|
+
# all-unchanged refresh. Fixed block, plain text, no new nouns.
|
|
871
|
+
if any(status_by_uuid.get(s["id"], "new") == "new" for s in promoted):
|
|
872
|
+
index_rel = (pkg / "CLAUDE.md").relative_to(project)
|
|
873
|
+
click.echo()
|
|
874
|
+
click.echo("Next steps:")
|
|
875
|
+
click.echo(
|
|
876
|
+
f" 1. Read {index_rel} — start with Conventions "
|
|
877
|
+
f"({len(index_schemas)} API"
|
|
878
|
+
f"{'s' if len(index_schemas) != 1 else ''} indexed).")
|
|
879
|
+
click.echo(
|
|
880
|
+
" 2. Then an API's example.py (runnable start), then its "
|
|
881
|
+
"README.md (full reference).")
|
|
882
|
+
click.echo(
|
|
883
|
+
" 3. Pass limit= on list/search calls unless exhaustive traversal "
|
|
884
|
+
"is intended — each page is a live, billed request.")
|
|
885
|
+
click.echo(
|
|
886
|
+
" 4. `uv run parse list` shows every API; `uv run parse sync` "
|
|
887
|
+
"refreshes after edits in Parse.")
|