Coverage for diagrams / code_diagrams / generate.py: 100.00%

240 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1#!/usr/bin/env python3 

2"""Generate code flowcharts for GCO using pyflowchart + Playwright. 

3 

4For each ``(source_file, function)`` target in :data:`TARGETS`: 

5 

61. Parse the function body with :mod:`pyflowchart` to produce a 

7 flowchart.js DSL string. 

82. Emit an interactive HTML page (flowchart.js renders client-side). 

93. Render the same diagram to a PNG using a headless Chromium via 

10 :mod:`playwright` (optional — when skipped or unavailable, any older PNG 

11 for that target is removed so artifact timestamps cannot disagree). 

124. Stamp the HTML, PNG, generated catalogue, and source marker with one 

13 invocation-wide UTC generation time. 

145. Insert (idempotently) a source comment near the top of the source file 

15 pointing at the generated HTML and PNG. 

16 

17Outputs mirror the source tree under ``diagrams/code_diagrams/``: 

18 

19 lambda/analytics-presigned-url/handler.py::lambda_handler 

20 -> diagrams/code_diagrams/lambda/analytics-presigned-url/handler.lambda_handler.{html,png} 

21 

22The README in ``diagrams/code_diagrams/`` is regenerated at the end 

23with a hierarchical, grouped-by-top-level-directory index so the 

24listing reflects the actual project layout. 

25 

26Usage: 

27 GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/code_diagrams/generate.py 

28 GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/code_diagrams/generate.py --target lambda/analytics-presigned-url/handler.py:lambda_handler 

29 GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/code_diagrams/generate.py --skip-png 

30 GCO_DIAGRAM_SOURCE_COMMIT=<40-char-sha> python diagrams/code_diagrams/generate.py --skip-marker 

31 python diagrams/code_diagrams/generate.py --strip-markers 

32""" 

33 

34from __future__ import annotations 

35 

36import argparse 

37import hashlib 

38import json 

39import re 

40import subprocess 

41import sys 

42from pathlib import Path 

43 

44# Add project root to path so direct script invocation works without a prior 

45# ``pip install -e .``. The project root is two parents up. 

46sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent)) 

47 

48from diagrams.code_diagrams._renderer import ( 

49 RenderedTarget, 

50 _output_stem_for, 

51 prune_orphaned_artifacts, 

52 render_all, 

53 write_readme, 

54) 

55from diagrams.code_diagrams._source_marker import ( 

56 SENTINEL, 

57 is_marker_source, 

58 strip_all_markers, 

59 strip_markers_from, 

60 upsert_markers, 

61) 

62from diagrams.code_diagrams._targets import TARGETS, Target 

63from diagrams.code_diagrams._timestamp import ( 

64 generation_source_commit, 

65 generation_timestamp_utc, 

66) 

67from gco.lambda_shared_sources import LAMBDA_SHARED_SOURCE_TARGETS 

68 

69_MARKER_BYTES_RE = re.compile( 

70 rb"(?:\r?\n)?# <" + re.escape(SENTINEL.encode()) + rb"> BEGIN[^\r\n]*\r?\n.*?" 

71 rb"# <" + re.escape(SENTINEL.encode()) + rb"> END(?:\r?\n){2}", 

72 re.DOTALL, 

73) 

74 

75#: Committed next to the catalogue README. Records, per charted source, the 

76#: SHA-256 of its marker-stripped bytes plus the timestamp and commit that 

77#: produced its artifacts. Two properties follow from keeping provenance 

78#: *per source* rather than catalogue-wide: 

79#: 

80#: 1. The contract check verifies working-tree sources against these digests 

81#: instead of resolving ``source_commit`` from Git history — a squash-merged 

82#: PR deletes its branch commits, so a recorded SHA is a human-readable 

83#: label, never a lookup key that must resolve in every future clone. 

84#: 2. Regeneration is incremental. Changing one charted file restamps only 

85#: that file's artifacts, so a PR's diagram diff stays proportional to the 

86#: code it actually touched instead of restamping all ~200 artifacts. 

87PROVENANCE_MANIFEST_NAME = "provenance.json" 

88 

89#: Bumped when the manifest layout changes. v1 recorded one catalogue-wide 

90#: ``generated_at`` / ``source_commit`` plus a flat ``source_digests`` map; 

91#: v2 records a per-source ``sources`` mapping. 

92PROVENANCE_SCHEMA_VERSION = 2 

93 

94#: Appended to every provenance failure so the fix never requires reading the 

95#: generator. Regeneration is incremental, so the command below re-renders 

96#: only the stale sources named in the message. 

97REGENERATION_HINT = ( 

98 "To fix: commit the source change first, then regenerate — " 

99 "``SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) " 

100 "GCO_DIAGRAM_SOURCE_COMMIT=$(git rev-parse HEAD) " 

101 "python diagrams/generate.py --code-only`` — and commit the result. " 

102 "The run is incremental: only the sources named above are re-rendered " 

103 "and restamped." 

104) 

105 

106 

107def _target_hint(sources: list[str]) -> str: 

108 """Suggest the narrowest regeneration command for ``sources``.""" 

109 if not sources or len(sources) > 4: 

110 return "" 

111 targets = " ".join(f"--target {source}:<function>" for source in sources) 

112 return ( 

113 " To re-render just these files, add " 

114 f"``{targets}`` (see the source's own marker block for its charted " 

115 "function names)." 

116 ) 

117 

118 

119def _without_generated_marker(source: bytes) -> bytes: 

120 """Remove only the generated marker bytes; preserve every other byte.""" 

121 return _MARKER_BYTES_RE.sub(b"", source) 

122 

123 

124def source_content_digest(source: bytes) -> str: 

125 """SHA-256 hex digest of a charted source with generated markers removed. 

126 

127 Marker blocks are excluded so that restamping timestamps/commits during 

128 regeneration never changes a source's recorded digest — only substantive 

129 code changes do. 

130 """ 

131 return hashlib.sha256(_without_generated_marker(source)).hexdigest() 

132 

133 

134def provenance_manifest_path(project_root: Path) -> Path: 

135 """Absolute path of the committed provenance manifest.""" 

136 return project_root / "diagrams" / "code_diagrams" / PROVENANCE_MANIFEST_NAME 

137 

138 

139def load_provenance_manifest(project_root: Path) -> dict[str, dict[str, str]]: 

140 """Return ``{source: {digest, generated_at, source_commit}}``. 

141 

142 Raises :class:`RuntimeError` with an actionable message when the manifest 

143 is absent or unreadable — the catalogue cannot be verified without it. 

144 """ 

145 path = provenance_manifest_path(project_root) 

146 try: 

147 raw = json.loads(path.read_text(encoding="utf-8")) 

148 except FileNotFoundError: 

149 raise RuntimeError( 

150 f"missing diagrams/code_diagrams/{PROVENANCE_MANIFEST_NAME}. {REGENERATION_HINT}" 

151 ) from None 

152 except (OSError, json.JSONDecodeError) as exc: 

153 raise RuntimeError( 

154 f"unreadable diagrams/code_diagrams/{PROVENANCE_MANIFEST_NAME}: {exc}. " 

155 f"{REGENERATION_HINT}" 

156 ) from exc 

157 

158 sources = raw.get("sources") 

159 if not isinstance(sources, dict) or not sources: 

160 raise RuntimeError(f"{PROVENANCE_MANIFEST_NAME} has no sources mapping") 

161 entries: dict[str, dict[str, str]] = {} 

162 for source, entry in sources.items(): 

163 if not isinstance(entry, dict) or not { 

164 "digest", 

165 "generated_at", 

166 "source_commit", 

167 } <= set(entry): 

168 raise RuntimeError( 

169 f"{PROVENANCE_MANIFEST_NAME} entry for {source} must record " 

170 "digest, generated_at, and source_commit" 

171 ) 

172 entries[source] = { 

173 key: str(entry[key]) for key in ("digest", "generated_at", "source_commit") 

174 } 

175 return entries 

176 

177 

178def newest_provenance_stamp(manifest: dict[str, dict[str, str]]) -> tuple[str, str]: 

179 """Return the ``(generated_at, source_commit)`` of the newest entry. 

180 

181 The catalogue README carries one stamp describing the most recent 

182 regeneration; per-source stamps live in the manifest and the markers. 

183 """ 

184 newest = max(manifest.values(), key=lambda entry: entry["generated_at"]) 

185 return newest["generated_at"], newest["source_commit"] 

186 

187 

188def write_provenance_manifest( 

189 *, 

190 project_root: Path, 

191 output_dir: Path, 

192 regenerated_targets: list[Target], 

193 generated_at: str, 

194 source_commit: str, 

195 catalog: list[Target], 

196) -> Path: 

197 """Merge this run's regenerated sources into the manifest and write it. 

198 

199 Entries for sources this run did not regenerate are preserved verbatim, 

200 which is what keeps an incremental run's diff small. Sources no longer in 

201 ``catalog`` are dropped. 

202 """ 

203 try: 

204 existing = load_provenance_manifest(project_root) 

205 except RuntimeError: 

206 existing = {} 

207 

208 charted = {target.source for target in catalog} 

209 merged = {source: entry for source, entry in existing.items() if source in charted} 

210 for source in sorted({target.source for target in regenerated_targets}): 

211 merged[source] = { 

212 "digest": source_content_digest((project_root / source).read_bytes()), 

213 "generated_at": generated_at, 

214 "source_commit": source_commit, 

215 } 

216 

217 manifest = {"schema_version": PROVENANCE_SCHEMA_VERSION, "sources": merged} 

218 path = output_dir / PROVENANCE_MANIFEST_NAME 

219 path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") 

220 return path 

221 

222 

223def select_stale_targets( 

224 *, 

225 project_root: Path, 

226 targets: list[Target], 

227 output_dir: Path, 

228) -> list[Target]: 

229 """Return the targets an incremental run must re-render. 

230 

231 A source is stale when its marker-stripped bytes no longer match the 

232 recorded digest (a substantive code change), when it has no recorded 

233 provenance at all (newly charted), or when either committed artifact of 

234 any of its targets is missing (for example, a function newly charted from 

235 an already-charted source). Every target of a stale source is selected: 

236 the source's marker lists all of its charted functions and one provenance 

237 stamp covers all of their artifacts, so re-rendering only some of them 

238 would drop the rest from the marker and restamp the source past their 

239 artifacts. Everything else is already current and is left byte-for-byte 

240 alone. 

241 """ 

242 try: 

243 manifest = load_provenance_manifest(project_root) 

244 except RuntimeError: 

245 return list(targets) 

246 

247 digest_cache: dict[str, str] = {} 

248 stale_sources: set[str] = set() 

249 for target in targets: 

250 if target.source in stale_sources: 

251 continue 

252 entry = manifest.get(target.source) 

253 if entry is None: 

254 stale_sources.add(target.source) 

255 continue 

256 if target.source not in digest_cache: 

257 digest_cache[target.source] = source_content_digest( 

258 (project_root / target.source).read_bytes() 

259 ) 

260 if digest_cache[target.source] != entry["digest"]: 

261 stale_sources.add(target.source) 

262 continue 

263 stem = _output_stem_for(target, output_dir=output_dir) 

264 if ( 

265 not stem.with_name(f"{stem.name}.html").is_file() 

266 or not stem.with_name(f"{stem.name}.png").is_file() 

267 ): 

268 stale_sources.add(target.source) 

269 return [target for target in targets if target.source in stale_sources] 

270 

271 

272def _verify_targets_match_source_commit( 

273 *, project_root: Path, targets: list[Target], source_commit: str 

274) -> None: 

275 """Require marker-excluded target bytes to equal a real Git commit.""" 

276 object_type = subprocess.run( # fixed Git command, hex-only ref 

277 ["git", "cat-file", "-t", source_commit], 

278 cwd=project_root, 

279 capture_output=True, 

280 check=False, 

281 ) 

282 if object_type.returncode != 0: 

283 detail = object_type.stderr.decode(errors="replace").strip() 

284 raise RuntimeError(f"GCO_DIAGRAM_SOURCE_COMMIT {source_commit} does not resolve: {detail}") 

285 if object_type.stdout.strip() != b"commit": 

286 actual_type = object_type.stdout.decode(errors="replace").strip() 

287 raise RuntimeError( 

288 f"GCO_DIAGRAM_SOURCE_COMMIT {source_commit} is a {actual_type}, not a commit" 

289 ) 

290 

291 mismatches: list[str] = [] 

292 for source in sorted({target.source for target in targets}): 

293 result = subprocess.run( # fixed Git command and catalog paths 

294 ["git", "show", f"{source_commit}:{source}"], 

295 cwd=project_root, 

296 capture_output=True, 

297 check=False, 

298 ) 

299 if result.returncode != 0: 

300 detail = result.stderr.decode(errors="replace").strip() 

301 raise RuntimeError( 

302 f"cannot read {source} from GCO_DIAGRAM_SOURCE_COMMIT {source_commit}: {detail}" 

303 ) 

304 current = (project_root / source).read_bytes() 

305 if _without_generated_marker(current) != _without_generated_marker(result.stdout): 

306 mismatches.append(source) 

307 if mismatches: 

308 raise RuntimeError( 

309 "charted source bytes differ from GCO_DIAGRAM_SOURCE_COMMIT after " 

310 f"removing only generated markers: {mismatches}. Commit substantive " 

311 "source changes first, then regenerate from that commit." 

312 ) 

313 

314 

315def verify_targets_match_provenance_manifest( 

316 *, project_root: Path, targets: list[Target] 

317) -> dict[str, dict[str, str]]: 

318 """Require charted source bytes to match the committed digest manifest. 

319 

320 This is the repository-side freshness contract, and it is deliberately 

321 self-contained: it compares working-tree bytes (markers stripped) against 

322 the SHA-256 digests recorded at generation time and never resolves a 

323 recorded commit from Git history. The generation-time check 

324 (:func:`_verify_targets_match_source_commit`) still anchors generation to a 

325 real committed state on the machine running the generator — but once 

326 committed, the catalogue must stay verifiable in any clone: a squash merge 

327 deletes branch commits, so a recorded SHA can legitimately be unreachable 

328 while the catalogue remains exactly current. 

329 

330 Returns the loaded manifest so the caller can cross-check per-source 

331 marker and artifact stamps against it. 

332 """ 

333 manifest = load_provenance_manifest(project_root) 

334 

335 charted = sorted({target.source for target in targets}) 

336 missing = sorted(set(charted) - set(manifest)) 

337 retired = sorted(set(manifest) - set(charted)) 

338 if missing or retired: 

339 detail = [] 

340 if missing: 

341 detail.append(f"newly charted sources with no recorded provenance: {missing}") 

342 if retired: 

343 detail.append(f"recorded sources no longer in _targets.py: {retired}") 

344 raise RuntimeError( 

345 f"{PROVENANCE_MANIFEST_NAME} is out of sync with the target catalogue " 

346 f"({'; '.join(detail)}). {REGENERATION_HINT}" 

347 ) 

348 

349 mismatches = [ 

350 source 

351 for source in charted 

352 if source_content_digest((project_root / source).read_bytes()) != manifest[source]["digest"] 

353 ] 

354 if mismatches: 

355 raise RuntimeError( 

356 "these charted sources changed since their flowcharts were generated, so " 

357 f"the committed diagrams no longer describe them: {mismatches}. " 

358 f"{REGENERATION_HINT}{_target_hint(mismatches)}" 

359 ) 

360 return manifest 

361 

362 

363def main() -> None: 

364 """CLI entry point.""" 

365 parser = argparse.ArgumentParser( 

366 description="Generate GCO code flowcharts (HTML + PNG).", 

367 ) 

368 parser.add_argument( 

369 "--target", 

370 action="append", 

371 default=None, 

372 metavar="PATH:FUNC", 

373 help=( 

374 "Only generate the named target(s). Repeatable. " 

375 "Format: ``path/to/file.py:function_name``. " 

376 "Default: all targets." 

377 ), 

378 ) 

379 parser.add_argument( 

380 "--all", 

381 dest="force_all", 

382 action="store_true", 

383 help=( 

384 "Re-render every target instead of only the sources whose bytes " 

385 "changed. Restamps the whole catalogue, so prefer the default " 

386 "incremental run unless you changed the generator itself." 

387 ), 

388 ) 

389 parser.add_argument( 

390 "--skip-png", 

391 action="store_true", 

392 help=( 

393 "Skip Playwright PNG rendering, remove selected targets' stale " 

394 "PNGs, and still write HTML." 

395 ), 

396 ) 

397 parser.add_argument( 

398 "--require-png", 

399 action="store_true", 

400 help="Fail a canonical run if any selected PNG could not be rendered.", 

401 ) 

402 parser.add_argument( 

403 "--skip-marker", 

404 action="store_true", 

405 help="Don't insert ``# Flowchart:`` markers into source files.", 

406 ) 

407 parser.add_argument( 

408 "--strip-markers", 

409 action="store_true", 

410 help=( 

411 "Remove every existing ``# <pyflowchart-code-diagram>`` " 

412 "block from the source tree and exit. Useful when " 

413 "refactoring the generator's placement rules or when " 

414 "tearing down the feature entirely. Does not regenerate " 

415 "flowcharts — combine with a normal run afterwards if " 

416 "you want fresh markers." 

417 ), 

418 ) 

419 args = parser.parse_args() 

420 if args.require_png and args.skip_png: 

421 parser.error("--require-png cannot be combined with --skip-png") 

422 

423 project_root = Path(__file__).resolve().parent.parent.parent 

424 output_dir = Path(__file__).resolve().parent 

425 

426 if args.strip_markers: 

427 print("🧹 Stripping pyflowchart markers from source files") 

428 print("=" * 50) 

429 print(f" Project root : {project_root}") 

430 modified = strip_all_markers(project_root) 

431 print("\n" + "=" * 50) 

432 print(f"✅ Stripped markers from {modified} file(s).") 

433 return 

434 

435 selected = _filter_targets(TARGETS, args.target) 

436 full_catalog = args.target is None 

437 generated_at = generation_timestamp_utc() 

438 source_commit = generation_source_commit() 

439 

440 # Incremental by default: re-render only the sources whose marker-stripped 

441 # bytes differ from the recorded provenance (plus newly charted targets and 

442 # any missing artifact). This is what keeps a PR's diagram diff 

443 # proportional to the code it changed. ``--all`` and an explicit 

444 # ``--target`` both bypass the staleness filter. 

445 incremental = full_catalog and not args.force_all 

446 targets = ( 

447 select_stale_targets( 

448 project_root=project_root, 

449 targets=selected, 

450 output_dir=output_dir, 

451 ) 

452 if incremental 

453 else selected 

454 ) 

455 

456 print("🧭 GCO Code Flowchart Generator") 

457 print("=" * 50) 

458 print(f" Project root : {project_root}") 

459 print(f" Output dir : {output_dir}") 

460 print(f" Mode : {'incremental' if incremental else 'full'}") 

461 print(f" Targets : {len(targets)} of {len(selected)} selected") 

462 print(f" Generated at : {generated_at}") 

463 print(f" Source commit: {source_commit}") 

464 

465 if not targets: 

466 # Nothing changed. Leave every committed artifact, marker, README, and 

467 # manifest byte untouched so a no-op regeneration is a no-op diff. 

468 print("\n✅ Every charted source is already current; nothing to re-render.") 

469 return 

470 

471 _verify_targets_match_source_commit( 

472 project_root=project_root, 

473 targets=targets, 

474 source_commit=source_commit, 

475 ) 

476 

477 results = render_all( 

478 targets=targets, 

479 project_root=project_root, 

480 output_dir=output_dir, 

481 render_png=not args.skip_png, 

482 generated_at=generated_at, 

483 source_commit=source_commit, 

484 ) 

485 if args.require_png: 

486 missing_pngs = [ 

487 result.target.source + ":" + result.target.function 

488 for result in results 

489 if result.png_path is None 

490 ] 

491 if missing_pngs: 

492 sys.exit(f"Canonical generation requires every PNG; missing: {missing_pngs}") 

493 

494 if not args.skip_marker: 

495 # Refresh markers only in the sources we just re-rendered, so an 

496 # incremental run leaves every other charted file byte-identical. 

497 # Retired sources still get their stale markers pruned; the shared 

498 # Lambda copies stay in the allowed set (see marker_allowed_sources). 

499 prune_retired_markers(project_root, charted=marker_allowed_sources(TARGETS)) 

500 upsert_markers(results, project_root=project_root) 

501 _sync_shared_lambda_copies(project_root) 

502 

503 if full_catalog: 

504 prune_orphaned_artifacts(targets=TARGETS, output_dir=output_dir) 

505 

506 # Provenance and the index are refreshed on every run, including an 

507 # explicit ``--target`` selection: a run that restamps a source's marker 

508 # and artifacts without recording the new stamp would leave the repository 

509 # contract failing. Entries for sources this run did not touch are 

510 # preserved verbatim (manifest) or reconstructed from their recorded 

511 # provenance (index), so a partial run stays a partial diff. 

512 manifest_path = write_provenance_manifest( 

513 project_root=project_root, 

514 output_dir=output_dir, 

515 regenerated_targets=targets, 

516 generated_at=generated_at, 

517 source_commit=source_commit, 

518 catalog=TARGETS, 

519 ) 

520 print(f"📝 Wrote {manifest_path}") 

521 write_readme( 

522 _catalog_readme_entries( 

523 project_root=project_root, 

524 output_dir=output_dir, 

525 results=results, 

526 ), 

527 output_dir=output_dir, 

528 ) 

529 

530 print("\n" + "=" * 50) 

531 print("✅ Code flowchart generation complete!") 

532 print(f" Output directory: {output_dir.absolute()}") 

533 

534 

535def marker_allowed_sources(targets: list[Target]) -> set[str]: 

536 """Return every source permitted to carry a generated marker block. 

537 

538 That is the charted sources plus the checked-in copies of charted shared 

539 Lambda sources — the copies are byte-identical to their canonical source 

540 (a separate contract), so they carry its marker too. Omitting them makes 

541 marker pruning strip the copies and desynchronise them, so this 

542 computation is shared by the generator and the contract checker rather 

543 than duplicated in both. 

544 """ 

545 allowed = {target.source for target in targets} 

546 for canonical, copies in LAMBDA_SHARED_SOURCE_TARGETS.items(): 

547 if canonical in allowed: 

548 allowed.update(copies) 

549 return allowed 

550 

551 

552def prune_retired_markers(project_root: Path, *, charted: set[str]) -> int: 

553 """Strip markers from files that are no longer charted targets. 

554 

555 A full ``strip_all_markers`` + reinsert pass would rewrite every charted 

556 source on every run, which is exactly the catalogue-wide churn incremental 

557 generation exists to avoid. Only genuinely retired sources are touched. 

558 """ 

559 modified = 0 

560 for source_path in sorted(project_root.rglob("*.py")): 

561 relative = source_path.relative_to(project_root).as_posix() 

562 if relative in charted or "-build" in relative: 

563 continue 

564 if not is_marker_source(relative): 

565 continue 

566 original = source_path.read_text(encoding="utf-8") 

567 if SENTINEL not in original: 

568 continue 

569 stripped = strip_markers_from(original) 

570 if stripped != original: 

571 source_path.write_text(stripped, encoding="utf-8") 

572 print(f" 🧹 stripped retired marker from {relative}") 

573 modified += 1 

574 return modified 

575 

576 

577def _catalog_readme_entries( 

578 *, 

579 project_root: Path, 

580 output_dir: Path, 

581 results: list[RenderedTarget], 

582) -> list[RenderedTarget]: 

583 """Return one entry per catalogue target for the README index. 

584 

585 Freshly rendered targets contribute their real results; every other target 

586 is reconstructed from its recorded provenance stamp and committed artifact 

587 paths, so an incremental run still writes a complete index without 

588 re-rendering (or restamping) the untouched majority. 

589 """ 

590 rendered = {(result.target.source, result.target.function): result for result in results} 

591 manifest = load_provenance_manifest(project_root) 

592 entries: list[RenderedTarget] = [] 

593 for target in TARGETS: 

594 existing = rendered.get((target.source, target.function)) 

595 if existing is not None: 

596 entries.append(existing) 

597 continue 

598 entry = manifest[target.source] 

599 stem = _output_stem_for(target, output_dir=output_dir) 

600 png_path = stem.with_name(f"{stem.name}.png") 

601 entries.append( 

602 RenderedTarget( 

603 target=target, 

604 html_path=stem.with_name(f"{stem.name}.html"), 

605 png_path=png_path if png_path.is_file() else None, 

606 generated_at=entry["generated_at"], 

607 source_commit=entry["source_commit"], 

608 ) 

609 ) 

610 return entries 

611 

612 

613def _sync_shared_lambda_copies(project_root: Path) -> None: 

614 """Propagate refreshed canonical shared Lambda sources to their copies. 

615 

616 ``upsert_markers`` rewrites the pyflowchart header inside canonical 

617 shared sources (``lambda/tls-shared/backend_tls.py``, 

618 ``lambda/proxy-shared/proxy_utils.py``), but the checked-in per-function 

619 copies are not diagram targets, so a regeneration used to leave them one 

620 header behind. That drift is exactly what 

621 ``tests/test_lambda_shared_sources.py`` rejects and what made every 

622 deploy rewrite tracked files mid-run (``StackManager._sync_lambda_sources`` 

623 re-syncs at deploy time). Reuse the deploy path's own map so the two 

624 sync points can never disagree about what a copy is. 

625 """ 

626 synced = 0 

627 for source_rel, target_rels in LAMBDA_SHARED_SOURCE_TARGETS.items(): 

628 source = project_root / source_rel 

629 if not source.is_file(): 

630 continue 

631 source_bytes = source.read_bytes() 

632 for target_rel in target_rels: 

633 target = project_root / target_rel 

634 if not target.parent.is_dir(): 

635 continue 

636 if target.is_file() and target.read_bytes() == source_bytes: 

637 continue 

638 target.write_bytes(source_bytes) 

639 synced += 1 

640 print(f" Synced shared copy: {target_rel} <- {source_rel}") 

641 if synced: 

642 print(f"🔁 Refreshed {synced} shared Lambda cop{'y' if synced == 1 else 'ies'}.") 

643 

644 

645def _filter_targets( 

646 all_targets: list[Target], 

647 requested: list[str] | None, 

648) -> list[Target]: 

649 """Filter :data:`TARGETS` by optional ``PATH:FUNC`` arguments.""" 

650 if not requested: 

651 return list(all_targets) 

652 wanted = set(requested) 

653 filtered = [t for t in all_targets if f"{t.source}:{t.function}" in wanted] 

654 missing = wanted - {f"{t.source}:{t.function}" for t in filtered} 

655 if missing: 

656 sys.exit(f"Unknown target(s): {sorted(missing)}") 

657 return filtered 

658 

659 

660if __name__ == "__main__": 

661 main()