Coverage for diagrams / code_diagrams / generate.py: 100.00%
238 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1#!/usr/bin/env python3
2"""Generate code flowcharts for GCO using pyflowchart + Playwright.
4For each ``(source_file, function)`` target in :data:`TARGETS`:
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.
17Outputs mirror the source tree under ``diagrams/code_diagrams/``:
19 lambda/analytics-presigned-url/handler.py::lambda_handler
20 -> diagrams/code_diagrams/lambda/analytics-presigned-url/handler.lambda_handler.{html,png}
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.
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"""
34from __future__ import annotations
36import argparse
37import hashlib
38import json
39import re
40import subprocess
41import sys
42from pathlib import Path
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))
48from diagrams.code_diagrams._renderer import ( # noqa: E402
49 RenderedTarget,
50 _output_stem_for,
51 prune_orphaned_artifacts,
52 render_all,
53 write_readme,
54)
55from diagrams.code_diagrams._source_marker import ( # noqa: E402
56 SENTINEL,
57 strip_all_markers,
58 strip_markers_from,
59 upsert_markers,
60)
61from diagrams.code_diagrams._targets import TARGETS, Target # noqa: E402
62from diagrams.code_diagrams._timestamp import ( # noqa: E402
63 generation_source_commit,
64 generation_timestamp_utc,
65)
66from gco.lambda_shared_sources import LAMBDA_SHARED_SOURCE_TARGETS # noqa: E402
68_MARKER_BYTES_RE = re.compile(
69 rb"(?:\r?\n)?# <" + re.escape(SENTINEL.encode()) + rb"> BEGIN[^\r\n]*\r?\n.*?"
70 rb"# <" + re.escape(SENTINEL.encode()) + rb"> END(?:\r?\n){2}",
71 re.DOTALL,
72)
74#: Committed next to the catalogue README. Records, per charted source, the
75#: SHA-256 of its marker-stripped bytes plus the timestamp and commit that
76#: produced its artifacts. Two properties follow from keeping provenance
77#: *per source* rather than catalogue-wide:
78#:
79#: 1. The contract check verifies working-tree sources against these digests
80#: instead of resolving ``source_commit`` from Git history — a squash-merged
81#: PR deletes its branch commits, so a recorded SHA is a human-readable
82#: label, never a lookup key that must resolve in every future clone.
83#: 2. Regeneration is incremental. Changing one charted file restamps only
84#: that file's artifacts, so a PR's diagram diff stays proportional to the
85#: code it actually touched instead of restamping all ~200 artifacts.
86PROVENANCE_MANIFEST_NAME = "provenance.json"
88#: Bumped when the manifest layout changes. v1 recorded one catalogue-wide
89#: ``generated_at`` / ``source_commit`` plus a flat ``source_digests`` map;
90#: v2 records a per-source ``sources`` mapping.
91PROVENANCE_SCHEMA_VERSION = 2
93#: Appended to every provenance failure so the fix never requires reading the
94#: generator. Regeneration is incremental, so the command below re-renders
95#: only the stale sources named in the message.
96REGENERATION_HINT = (
97 "To fix: commit the source change first, then regenerate — "
98 "``SOURCE_DATE_EPOCH=$(git show -s --format=%ct HEAD) "
99 "GCO_DIAGRAM_SOURCE_COMMIT=$(git rev-parse HEAD) "
100 "python diagrams/generate.py --code-only`` — and commit the result. "
101 "The run is incremental: only the sources named above are re-rendered "
102 "and restamped."
103)
106def _target_hint(sources: list[str]) -> str:
107 """Suggest the narrowest regeneration command for ``sources``."""
108 if not sources or len(sources) > 4:
109 return ""
110 targets = " ".join(f"--target {source}:<function>" for source in sources)
111 return (
112 " To re-render just these files, add "
113 f"``{targets}`` (see the source's own marker block for its charted "
114 "function names)."
115 )
118def _without_generated_marker(source: bytes) -> bytes:
119 """Remove only the generated marker bytes; preserve every other byte."""
120 return _MARKER_BYTES_RE.sub(b"", source)
123def source_content_digest(source: bytes) -> str:
124 """SHA-256 hex digest of a charted source with generated markers removed.
126 Marker blocks are excluded so that restamping timestamps/commits during
127 regeneration never changes a source's recorded digest — only substantive
128 code changes do.
129 """
130 return hashlib.sha256(_without_generated_marker(source)).hexdigest()
133def provenance_manifest_path(project_root: Path) -> Path:
134 """Absolute path of the committed provenance manifest."""
135 return project_root / "diagrams" / "code_diagrams" / PROVENANCE_MANIFEST_NAME
138def load_provenance_manifest(project_root: Path) -> dict[str, dict[str, str]]:
139 """Return ``{source: {digest, generated_at, source_commit}}``.
141 Raises :class:`RuntimeError` with an actionable message when the manifest
142 is absent or unreadable — the catalogue cannot be verified without it.
143 """
144 path = provenance_manifest_path(project_root)
145 try:
146 raw = json.loads(path.read_text(encoding="utf-8"))
147 except FileNotFoundError:
148 raise RuntimeError(
149 f"missing diagrams/code_diagrams/{PROVENANCE_MANIFEST_NAME}. {REGENERATION_HINT}"
150 ) from None
151 except (OSError, json.JSONDecodeError) as exc:
152 raise RuntimeError(
153 f"unreadable diagrams/code_diagrams/{PROVENANCE_MANIFEST_NAME}: {exc}. "
154 f"{REGENERATION_HINT}"
155 ) from exc
157 sources = raw.get("sources")
158 if not isinstance(sources, dict) or not sources:
159 raise RuntimeError(f"{PROVENANCE_MANIFEST_NAME} has no sources mapping")
160 entries: dict[str, dict[str, str]] = {}
161 for source, entry in sources.items():
162 if not isinstance(entry, dict) or not {
163 "digest",
164 "generated_at",
165 "source_commit",
166 } <= set(entry):
167 raise RuntimeError(
168 f"{PROVENANCE_MANIFEST_NAME} entry for {source} must record "
169 "digest, generated_at, and source_commit"
170 )
171 entries[source] = {
172 key: str(entry[key]) for key in ("digest", "generated_at", "source_commit")
173 }
174 return entries
177def newest_provenance_stamp(manifest: dict[str, dict[str, str]]) -> tuple[str, str]:
178 """Return the ``(generated_at, source_commit)`` of the newest entry.
180 The catalogue README carries one stamp describing the most recent
181 regeneration; per-source stamps live in the manifest and the markers.
182 """
183 newest = max(manifest.values(), key=lambda entry: entry["generated_at"])
184 return newest["generated_at"], newest["source_commit"]
187def write_provenance_manifest(
188 *,
189 project_root: Path,
190 output_dir: Path,
191 regenerated_targets: list[Target],
192 generated_at: str,
193 source_commit: str,
194 catalog: list[Target],
195) -> Path:
196 """Merge this run's regenerated sources into the manifest and write it.
198 Entries for sources this run did not regenerate are preserved verbatim,
199 which is what keeps an incremental run's diff small. Sources no longer in
200 ``catalog`` are dropped.
201 """
202 try:
203 existing = load_provenance_manifest(project_root)
204 except RuntimeError:
205 existing = {}
207 charted = {target.source for target in catalog}
208 merged = {source: entry for source, entry in existing.items() if source in charted}
209 for source in sorted({target.source for target in regenerated_targets}):
210 merged[source] = {
211 "digest": source_content_digest((project_root / source).read_bytes()),
212 "generated_at": generated_at,
213 "source_commit": source_commit,
214 }
216 manifest = {"schema_version": PROVENANCE_SCHEMA_VERSION, "sources": merged}
217 path = output_dir / PROVENANCE_MANIFEST_NAME
218 path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
219 return path
222def select_stale_targets(
223 *,
224 project_root: Path,
225 targets: list[Target],
226 output_dir: Path,
227) -> list[Target]:
228 """Return the targets an incremental run must re-render.
230 A target is stale when its source's marker-stripped bytes no longer match
231 the recorded digest (a substantive code change), when the source has no
232 recorded provenance at all (newly charted), or when either committed
233 artifact is missing. Everything else is already current and is left byte-
234 for-byte alone.
235 """
236 try:
237 manifest = load_provenance_manifest(project_root)
238 except RuntimeError:
239 return list(targets)
241 digest_cache: dict[str, str] = {}
242 stale: list[Target] = []
243 for target in targets:
244 entry = manifest.get(target.source)
245 if entry is None:
246 stale.append(target)
247 continue
248 if target.source not in digest_cache:
249 digest_cache[target.source] = source_content_digest(
250 (project_root / target.source).read_bytes()
251 )
252 if digest_cache[target.source] != entry["digest"]:
253 stale.append(target)
254 continue
255 stem = _output_stem_for(target, output_dir=output_dir)
256 if (
257 not stem.with_name(f"{stem.name}.html").is_file()
258 or not stem.with_name(f"{stem.name}.png").is_file()
259 ):
260 stale.append(target)
261 return stale
264def _verify_targets_match_source_commit(
265 *, project_root: Path, targets: list[Target], source_commit: str
266) -> None:
267 """Require marker-excluded target bytes to equal a real Git commit."""
268 object_type = subprocess.run( # noqa: S603 — fixed Git command, hex-only ref
269 ["git", "cat-file", "-t", source_commit],
270 cwd=project_root,
271 capture_output=True,
272 check=False,
273 )
274 if object_type.returncode != 0:
275 detail = object_type.stderr.decode(errors="replace").strip()
276 raise RuntimeError(f"GCO_DIAGRAM_SOURCE_COMMIT {source_commit} does not resolve: {detail}")
277 if object_type.stdout.strip() != b"commit":
278 actual_type = object_type.stdout.decode(errors="replace").strip()
279 raise RuntimeError(
280 f"GCO_DIAGRAM_SOURCE_COMMIT {source_commit} is a {actual_type}, not a commit"
281 )
283 mismatches: list[str] = []
284 for source in sorted({target.source for target in targets}):
285 result = subprocess.run( # noqa: S603 — fixed Git command and catalog paths
286 ["git", "show", f"{source_commit}:{source}"],
287 cwd=project_root,
288 capture_output=True,
289 check=False,
290 )
291 if result.returncode != 0:
292 detail = result.stderr.decode(errors="replace").strip()
293 raise RuntimeError(
294 f"cannot read {source} from GCO_DIAGRAM_SOURCE_COMMIT {source_commit}: {detail}"
295 )
296 current = (project_root / source).read_bytes()
297 if _without_generated_marker(current) != _without_generated_marker(result.stdout):
298 mismatches.append(source)
299 if mismatches:
300 raise RuntimeError(
301 "charted source bytes differ from GCO_DIAGRAM_SOURCE_COMMIT after "
302 f"removing only generated markers: {mismatches}. Commit substantive "
303 "source changes first, then regenerate from that commit."
304 )
307def verify_targets_match_provenance_manifest(
308 *, project_root: Path, targets: list[Target]
309) -> dict[str, dict[str, str]]:
310 """Require charted source bytes to match the committed digest manifest.
312 This is the repository-side freshness contract, and it is deliberately
313 self-contained: it compares working-tree bytes (markers stripped) against
314 the SHA-256 digests recorded at generation time and never resolves a
315 recorded commit from Git history. The generation-time check
316 (:func:`_verify_targets_match_source_commit`) still anchors generation to a
317 real committed state on the machine running the generator — but once
318 committed, the catalogue must stay verifiable in any clone: a squash merge
319 deletes branch commits, so a recorded SHA can legitimately be unreachable
320 while the catalogue remains exactly current.
322 Returns the loaded manifest so the caller can cross-check per-source
323 marker and artifact stamps against it.
324 """
325 manifest = load_provenance_manifest(project_root)
327 charted = sorted({target.source for target in targets})
328 missing = sorted(set(charted) - set(manifest))
329 retired = sorted(set(manifest) - set(charted))
330 if missing or retired:
331 detail = []
332 if missing:
333 detail.append(f"newly charted sources with no recorded provenance: {missing}")
334 if retired:
335 detail.append(f"recorded sources no longer in _targets.py: {retired}")
336 raise RuntimeError(
337 f"{PROVENANCE_MANIFEST_NAME} is out of sync with the target catalogue "
338 f"({'; '.join(detail)}). {REGENERATION_HINT}"
339 )
341 mismatches = [
342 source
343 for source in charted
344 if source_content_digest((project_root / source).read_bytes()) != manifest[source]["digest"]
345 ]
346 if mismatches:
347 raise RuntimeError(
348 "these charted sources changed since their flowcharts were generated, so "
349 f"the committed diagrams no longer describe them: {mismatches}. "
350 f"{REGENERATION_HINT}{_target_hint(mismatches)}"
351 )
352 return manifest
355def main() -> None:
356 """CLI entry point."""
357 parser = argparse.ArgumentParser(
358 description="Generate GCO code flowcharts (HTML + PNG).",
359 )
360 parser.add_argument(
361 "--target",
362 action="append",
363 default=None,
364 metavar="PATH:FUNC",
365 help=(
366 "Only generate the named target(s). Repeatable. "
367 "Format: ``path/to/file.py:function_name``. "
368 "Default: all targets."
369 ),
370 )
371 parser.add_argument(
372 "--all",
373 dest="force_all",
374 action="store_true",
375 help=(
376 "Re-render every target instead of only the sources whose bytes "
377 "changed. Restamps the whole catalogue, so prefer the default "
378 "incremental run unless you changed the generator itself."
379 ),
380 )
381 parser.add_argument(
382 "--skip-png",
383 action="store_true",
384 help=(
385 "Skip Playwright PNG rendering, remove selected targets' stale "
386 "PNGs, and still write HTML."
387 ),
388 )
389 parser.add_argument(
390 "--require-png",
391 action="store_true",
392 help="Fail a canonical run if any selected PNG could not be rendered.",
393 )
394 parser.add_argument(
395 "--skip-marker",
396 action="store_true",
397 help="Don't insert ``# Flowchart:`` markers into source files.",
398 )
399 parser.add_argument(
400 "--strip-markers",
401 action="store_true",
402 help=(
403 "Remove every existing ``# <pyflowchart-code-diagram>`` "
404 "block from the source tree and exit. Useful when "
405 "refactoring the generator's placement rules or when "
406 "tearing down the feature entirely. Does not regenerate "
407 "flowcharts — combine with a normal run afterwards if "
408 "you want fresh markers."
409 ),
410 )
411 args = parser.parse_args()
412 if args.require_png and args.skip_png:
413 parser.error("--require-png cannot be combined with --skip-png")
415 project_root = Path(__file__).resolve().parent.parent.parent
416 output_dir = Path(__file__).resolve().parent
418 if args.strip_markers:
419 print("🧹 Stripping pyflowchart markers from source files")
420 print("=" * 50)
421 print(f" Project root : {project_root}")
422 modified = strip_all_markers(project_root)
423 print("\n" + "=" * 50)
424 print(f"✅ Stripped markers from {modified} file(s).")
425 return
427 selected = _filter_targets(TARGETS, args.target)
428 full_catalog = args.target is None
429 generated_at = generation_timestamp_utc()
430 source_commit = generation_source_commit()
432 # Incremental by default: re-render only the sources whose marker-stripped
433 # bytes differ from the recorded provenance (plus newly charted targets and
434 # any missing artifact). This is what keeps a PR's diagram diff
435 # proportional to the code it changed. ``--all`` and an explicit
436 # ``--target`` both bypass the staleness filter.
437 incremental = full_catalog and not args.force_all
438 targets = (
439 select_stale_targets(
440 project_root=project_root,
441 targets=selected,
442 output_dir=output_dir,
443 )
444 if incremental
445 else selected
446 )
448 print("🧭 GCO Code Flowchart Generator")
449 print("=" * 50)
450 print(f" Project root : {project_root}")
451 print(f" Output dir : {output_dir}")
452 print(f" Mode : {'incremental' if incremental else 'full'}")
453 print(f" Targets : {len(targets)} of {len(selected)} selected")
454 print(f" Generated at : {generated_at}")
455 print(f" Source commit: {source_commit}")
457 if not targets:
458 # Nothing changed. Leave every committed artifact, marker, README, and
459 # manifest byte untouched so a no-op regeneration is a no-op diff.
460 print("\n✅ Every charted source is already current; nothing to re-render.")
461 return
463 _verify_targets_match_source_commit(
464 project_root=project_root,
465 targets=targets,
466 source_commit=source_commit,
467 )
469 results = render_all(
470 targets=targets,
471 project_root=project_root,
472 output_dir=output_dir,
473 render_png=not args.skip_png,
474 generated_at=generated_at,
475 source_commit=source_commit,
476 )
477 if args.require_png:
478 missing_pngs = [
479 result.target.source + ":" + result.target.function
480 for result in results
481 if result.png_path is None
482 ]
483 if missing_pngs:
484 sys.exit(f"Canonical generation requires every PNG; missing: {missing_pngs}")
486 if not args.skip_marker:
487 # Refresh markers only in the sources we just re-rendered, so an
488 # incremental run leaves every other charted file byte-identical.
489 # Retired sources still get their stale markers pruned; the shared
490 # Lambda copies stay in the allowed set (see marker_allowed_sources).
491 prune_retired_markers(project_root, charted=marker_allowed_sources(TARGETS))
492 upsert_markers(results, project_root=project_root)
493 _sync_shared_lambda_copies(project_root)
495 if full_catalog:
496 prune_orphaned_artifacts(targets=TARGETS, output_dir=output_dir)
498 # Provenance and the index are refreshed on every run, including an
499 # explicit ``--target`` selection: a run that restamps a source's marker
500 # and artifacts without recording the new stamp would leave the repository
501 # contract failing. Entries for sources this run did not touch are
502 # preserved verbatim (manifest) or reconstructed from their recorded
503 # provenance (index), so a partial run stays a partial diff.
504 manifest_path = write_provenance_manifest(
505 project_root=project_root,
506 output_dir=output_dir,
507 regenerated_targets=targets,
508 generated_at=generated_at,
509 source_commit=source_commit,
510 catalog=TARGETS,
511 )
512 print(f"📝 Wrote {manifest_path}")
513 write_readme(
514 _catalog_readme_entries(
515 project_root=project_root,
516 output_dir=output_dir,
517 results=results,
518 ),
519 output_dir=output_dir,
520 )
522 print("\n" + "=" * 50)
523 print("✅ Code flowchart generation complete!")
524 print(f" Output directory: {output_dir.absolute()}")
527def marker_allowed_sources(targets: list[Target]) -> set[str]:
528 """Return every source permitted to carry a generated marker block.
530 That is the charted sources plus the checked-in copies of charted shared
531 Lambda sources — the copies are byte-identical to their canonical source
532 (a separate contract), so they carry its marker too. Omitting them makes
533 marker pruning strip the copies and desynchronise them, so this
534 computation is shared by the generator and the contract checker rather
535 than duplicated in both.
536 """
537 allowed = {target.source for target in targets}
538 for canonical, copies in LAMBDA_SHARED_SOURCE_TARGETS.items():
539 if canonical in allowed:
540 allowed.update(copies)
541 return allowed
544def prune_retired_markers(project_root: Path, *, charted: set[str]) -> int:
545 """Strip markers from files that are no longer charted targets.
547 A full ``strip_all_markers`` + reinsert pass would rewrite every charted
548 source on every run, which is exactly the catalogue-wide churn incremental
549 generation exists to avoid. Only genuinely retired sources are touched.
550 """
551 modified = 0
552 for source_path in sorted(project_root.rglob("*.py")):
553 relative = source_path.relative_to(project_root).as_posix()
554 if relative in charted or "-build" in relative:
555 continue
556 if not relative.startswith(("app.py", "cli/", "gco/", "gco_mcp/", "lambda/")):
557 continue
558 original = source_path.read_text(encoding="utf-8")
559 if SENTINEL not in original:
560 continue
561 stripped = strip_markers_from(original)
562 if stripped != original:
563 source_path.write_text(stripped, encoding="utf-8")
564 print(f" 🧹 stripped retired marker from {relative}")
565 modified += 1
566 return modified
569def _catalog_readme_entries(
570 *,
571 project_root: Path,
572 output_dir: Path,
573 results: list[RenderedTarget],
574) -> list[RenderedTarget]:
575 """Return one entry per catalogue target for the README index.
577 Freshly rendered targets contribute their real results; every other target
578 is reconstructed from its recorded provenance stamp and committed artifact
579 paths, so an incremental run still writes a complete index without
580 re-rendering (or restamping) the untouched majority.
581 """
582 rendered = {(result.target.source, result.target.function): result for result in results}
583 manifest = load_provenance_manifest(project_root)
584 entries: list[RenderedTarget] = []
585 for target in TARGETS:
586 existing = rendered.get((target.source, target.function))
587 if existing is not None:
588 entries.append(existing)
589 continue
590 entry = manifest[target.source]
591 stem = _output_stem_for(target, output_dir=output_dir)
592 png_path = stem.with_name(f"{stem.name}.png")
593 entries.append(
594 RenderedTarget(
595 target=target,
596 html_path=stem.with_name(f"{stem.name}.html"),
597 png_path=png_path if png_path.is_file() else None,
598 generated_at=entry["generated_at"],
599 source_commit=entry["source_commit"],
600 )
601 )
602 return entries
605def _sync_shared_lambda_copies(project_root: Path) -> None:
606 """Propagate refreshed canonical shared Lambda sources to their copies.
608 ``upsert_markers`` rewrites the pyflowchart header inside canonical
609 shared sources (``lambda/tls-shared/backend_tls.py``,
610 ``lambda/proxy-shared/proxy_utils.py``), but the checked-in per-function
611 copies are not diagram targets, so a regeneration used to leave them one
612 header behind. That drift is exactly what
613 ``tests/test_lambda_shared_sources.py`` rejects and what made every
614 deploy rewrite tracked files mid-run (``StackManager._sync_lambda_sources``
615 re-syncs at deploy time). Reuse the deploy path's own map so the two
616 sync points can never disagree about what a copy is.
617 """
618 synced = 0
619 for source_rel, target_rels in LAMBDA_SHARED_SOURCE_TARGETS.items():
620 source = project_root / source_rel
621 if not source.is_file():
622 continue
623 source_bytes = source.read_bytes()
624 for target_rel in target_rels:
625 target = project_root / target_rel
626 if not target.parent.is_dir():
627 continue
628 if target.is_file() and target.read_bytes() == source_bytes:
629 continue
630 target.write_bytes(source_bytes)
631 synced += 1
632 print(f" Synced shared copy: {target_rel} <- {source_rel}")
633 if synced:
634 print(f"🔁 Refreshed {synced} shared Lambda cop{'y' if synced == 1 else 'ies'}.")
637def _filter_targets(
638 all_targets: list[Target],
639 requested: list[str] | None,
640) -> list[Target]:
641 """Filter :data:`TARGETS` by optional ``PATH:FUNC`` arguments."""
642 if not requested:
643 return list(all_targets)
644 wanted = set(requested)
645 filtered = [t for t in all_targets if f"{t.source}:{t.function}" in wanted]
646 missing = wanted - {f"{t.source}:{t.function}" for t in filtered}
647 if missing:
648 sys.exit(f"Unknown target(s): {sorted(missing)}")
649 return filtered
652if __name__ == "__main__":
653 main()