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/_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.")