chandra 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.
chandra/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """chandra — tools for the metadata image generators embed in their output.
2
+
3
+ Everything is dispatched through a single `chandra` entry point:
4
+
5
+ - ``chandra show`` — print the A1111/CivitAI-compatible metadata derived from an embedded
6
+ ComfyUI workflow (read-only).
7
+ - ``chandra inject`` — write that metadata into the image(s) in place, so they're recognized by
8
+ services that don't analyze ComfyUI graphs themselves.
9
+ - ``chandra eject`` — remove that metadata again (the inverse of inject), restoring the image to
10
+ its pre-inject state.
11
+ - ``chandra search`` — search the prompts embedded across a directory tree of generated images.
12
+ - ``chandra scrub`` — strip a ComfyUI image to a de-branded, shareable skeleton.
13
+
14
+ Three engines do the work: `rosetta` (analyze + synthesize + inject/eject, behind show/inject/eject),
15
+ `concordance` (behind search), and `palimpsest` (behind scrub). See the README for the naming lore.
16
+ """
17
+
18
+ from importlib.metadata import PackageNotFoundError, version
19
+
20
+ try:
21
+ __version__ = version("chandra")
22
+ except PackageNotFoundError: # running from a source tree without an installed dist
23
+ __version__ = "0.0.0+unknown"
24
+
25
+ # Signature stamped into everything `inject` writes — the A1111 `Version:` field and the XMP packet's
26
+ # `x:xmptk` (toolkit) attribute — so `eject` can recognize chandra's own output and remove only that,
27
+ # never a third party's metadata. The `rosetta` suffix names the engine that writes it (README lore).
28
+ TOOL_TAG = "chandra-rosetta"
29
+
30
+ __all__ = ["__version__", "TOOL_TAG"]
chandra/analyze.py ADDED
@@ -0,0 +1,451 @@
1
+ """Analyze a ComfyUI `prompt` graph into a normalized `Recipe`.
2
+
3
+ This is the heart of `rosetta`. Both CivitAI and SD Prompt Reader punt on non-trivial ComfyUI
4
+ graphs; we walk the graph ourselves and reduce it to the generation recipe, which synthesis then
5
+ renders as an A1111 `parameters` string. See `briefs/rosetta-metadata-injector.md`.
6
+
7
+ The walk is **role-based**, never keyed on node id or exact class name (those vary across node packs
8
+ and templates): we recognize a node by the shape of its inputs. We traverse the API-format `prompt`
9
+ graph — `{node_id: {"class_type", "inputs"}}`, each input either a literal or a `[node_id, slot]`
10
+ link — which is the *executed* graph (bypassed/muted/UI-only nodes are already absent), so toggled-off
11
+ LoRAs and reference chains need no special handling.
12
+
13
+ Strategy: find the Save-Image sink → walk to the sampler feeding it → read the sampler's scalars
14
+ (resolving literals, `Primitive*` values, and `Evaluate*` expressions) → trace its positive/negative
15
+ conditioning back through passthrough nodes to the text encoders → walk its model link through the
16
+ LoRA chain to the base loader. Image size comes from the PNG `IHDR`, not the graph (the most reliable
17
+ source, and Flux.2's latent geometry differs from older models / inpaint sizes are crop regions).
18
+ """
19
+
20
+ import math
21
+ from dataclasses import dataclass, field
22
+ from typing import Optional
23
+
24
+ try:
25
+ from simpleeval import simple_eval
26
+ except ImportError: # resolver degrades gracefully without it
27
+ simple_eval = None
28
+
29
+ __all__ = ["Lora", "Recipe", "analyze", "conditioning_roles", "format_recipe", "format_description"]
30
+
31
+ _MAX_DEPTH = 64 # guard against cycles/pathological graphs (the graph is a DAG, but a file may lie)
32
+
33
+ # Whitelisted functions for Evaluate* expressions, on top of simpleeval's safe arithmetic.
34
+ _SAFE_FUNCS = {
35
+ "int": int, "float": float, "round": round, "abs": abs,
36
+ "min": min, "max": max, "ceil": math.ceil, "floor": math.floor, "pow": pow,
37
+ }
38
+
39
+
40
+ @dataclass
41
+ class Lora:
42
+ name: str
43
+ strength: Optional[float] = None
44
+ hash: Optional[str] = None # AutoV2, filled in by the hashing step (--hash)
45
+
46
+
47
+ @dataclass
48
+ class Recipe:
49
+ """The generation recipe extracted from a ComfyUI graph. Fields are None when unresolved."""
50
+ positive: Optional[str] = None
51
+ negative: Optional[str] = None
52
+ seed: Optional[object] = None # int in practice; kept loose for odd graphs
53
+ steps: Optional[object] = None
54
+ cfg: Optional[object] = None
55
+ sampler_name: Optional[str] = None
56
+ scheduler: Optional[str] = None
57
+ denoise: Optional[object] = None
58
+ model: Optional[str] = None
59
+ model_hash: Optional[str] = None # AutoV2, filled in by the hashing step (--hash)
60
+ loras: list = field(default_factory=list)
61
+ vae: Optional[str] = None
62
+ vae_hash: Optional[str] = None # AutoV2, filled in by the hashing step (--hash)
63
+ text_encoders: list = field(default_factory=list) # separate CLIP/T5 loader files (Forge "VAE/TE")
64
+ width: Optional[int] = None
65
+ height: Optional[int] = None
66
+ sampler_class: Optional[str] = None
67
+ warnings: list = field(default_factory=list)
68
+
69
+
70
+ # --------------------------------------------------------------------------------
71
+ # Role predicates — recognize a node by the shape of its inputs, not its exact class name.
72
+
73
+ def _inputs(node) -> dict:
74
+ return node.get("inputs", {}) if node else {}
75
+
76
+
77
+ def _is_link(v) -> bool:
78
+ return isinstance(v, list) and len(v) == 2 and isinstance(v[0], str)
79
+
80
+
81
+ def _is_sampler(node) -> bool:
82
+ ins = _inputs(node)
83
+ has_contract = "steps" in ins and ("cfg" in ins or "sampler_name" in ins)
84
+ return has_contract or "sampler" in node.get("class_type", "").lower()
85
+
86
+
87
+ def _is_save(node) -> bool:
88
+ ct = node.get("class_type", "").lower()
89
+ return "save" in ct and "image" in ct
90
+
91
+
92
+ def _is_base_loader_field(name: str) -> bool:
93
+ return name in ("ckpt_name", "gguf_name", "unet_name", "model_path")
94
+
95
+
96
+ # --------------------------------------------------------------------------------
97
+ # Scalar resolution: literal | Primitive*.value | Evaluate* expression.
98
+
99
+ def _resolve_value(graph, v, depth=0):
100
+ """Resolve a scalar input to a concrete value, or None if it can't be resolved honestly."""
101
+ if not _is_link(v):
102
+ return v # already a literal
103
+ if depth > _MAX_DEPTH:
104
+ return None
105
+ node = graph.get(v[0])
106
+ if node is None:
107
+ return None
108
+ ins = _inputs(node)
109
+ ct = node.get("class_type", "")
110
+
111
+ if "python_expression" in ins: # Evaluate Integers / Evaluate Floats (and kin)
112
+ if simple_eval is None:
113
+ return None
114
+ names = {name: _resolve_value(graph, ins.get(name, 0), depth + 1) for name in ("a", "b", "c")}
115
+ if any(val is None for val in names.values()):
116
+ return None
117
+ try:
118
+ return simple_eval(ins["python_expression"], names=names, functions=_SAFE_FUNCS)
119
+ except Exception:
120
+ return None
121
+
122
+ if ct.startswith("Primitive") or "value" in ins: # PrimitiveInt/Float/String → value
123
+ return _resolve_value(graph, ins.get("value"), depth + 1)
124
+
125
+ return None # unknown scalar source → unresolved (honest fallback)
126
+
127
+
128
+ def _resolve_string(graph, ref, depth=0):
129
+ """A text field may be a literal or a link to an upstream string-bearing node."""
130
+ if isinstance(ref, str):
131
+ return ref
132
+ if not _is_link(ref) or depth > _MAX_DEPTH:
133
+ return None
134
+ node = graph.get(ref[0])
135
+ ins = _inputs(node)
136
+ for key in ("value", "text", "string", "prompt"):
137
+ if key in ins:
138
+ return _resolve_string(graph, ins[key], depth + 1)
139
+ return None
140
+
141
+
142
+ # --------------------------------------------------------------------------------
143
+ # Graph navigation
144
+
145
+ def _subgraph_size(graph, start_id):
146
+ """Count nodes reachable upstream from start_id (for picking the dominant sink)."""
147
+ seen, stack = set(), [start_id]
148
+ while stack:
149
+ nid = stack.pop()
150
+ if nid in seen or nid not in graph:
151
+ continue
152
+ seen.add(nid)
153
+ for v in _inputs(graph[nid]).values():
154
+ if _is_link(v):
155
+ stack.append(v[0])
156
+ return len(seen)
157
+
158
+
159
+ def _pick_sink(graph, warnings):
160
+ saves = [nid for nid, node in graph.items() if _is_save(node)]
161
+ if not saves:
162
+ # Fallback: no Save-Image node — treat the largest sampler subgraph as the endpoint.
163
+ samplers = [nid for nid, node in graph.items() if _is_sampler(node)]
164
+ if not samplers:
165
+ warnings.append("no Save-Image or sampler node found")
166
+ return None
167
+ warnings.append("no Save-Image node; using the largest sampler subgraph")
168
+ return max(samplers, key=lambda nid: _subgraph_size(graph, nid))
169
+ if len(saves) > 1:
170
+ warnings.append(f"{len(saves)} Save-Image nodes; using the one with the largest subgraph")
171
+ return max(saves, key=lambda nid: _subgraph_size(graph, nid))
172
+
173
+
174
+ # Inputs to follow on the produced-image path from the sink toward the sampler, in priority order.
175
+ # (Latent/sample path first, then composited-image inputs — e.g. inpaint crop-and-stitch.)
176
+ _IMAGE_PATH_KEYS = ("samples", "latent", "inpainted_image", "stitched_image", "image", "images")
177
+
178
+
179
+ def _next_image_ref(ins):
180
+ """The link to follow toward the sampler: a known image/latent input, else a generic one.
181
+
182
+ Source-side inputs (mask, vae, the original `pixels`/`stitcher`) are excluded so we follow the
183
+ *produced* image, not the inputs that fed the inpaint/img2img region.
184
+ """
185
+ for key in _IMAGE_PATH_KEYS:
186
+ if _is_link(ins.get(key)):
187
+ return ins[key]
188
+ for k, v in ins.items():
189
+ kl = k.lower()
190
+ if _is_link(v) and any(t in kl for t in ("image", "latent", "sample")) \
191
+ and not any(t in kl for t in ("mask", "vae", "pixel", "stitch")):
192
+ return v
193
+ return None
194
+
195
+
196
+ def _find_sampler(graph, sink_id, warnings):
197
+ """From the sink, follow the produced-image path until a sampler-role node is reached."""
198
+ node = graph.get(sink_id)
199
+ if _is_sampler(node): # sink fallback was itself a sampler
200
+ return sink_id
201
+ ref = _inputs(node).get("images") if node else None
202
+ seen = set()
203
+ while _is_link(ref) and ref[0] not in seen:
204
+ nid = ref[0]
205
+ seen.add(nid)
206
+ cur = graph.get(nid)
207
+ if cur is None:
208
+ break
209
+ if _is_sampler(cur):
210
+ return nid
211
+ ref = _next_image_ref(_inputs(cur))
212
+ warnings.append("could not locate a sampler upstream of the sink")
213
+ return None
214
+
215
+
216
+ def _trace_conditioning_node(graph, ref, depth=0):
217
+ """Follow a conditioning link back to the text-encoder node; return its node id (or None).
218
+
219
+ Handles direct encoders (`text`/`prompt`), single-conditioning passthroughs (`ReferenceLatent`,
220
+ …), and dual-output passthroughs (`InpaintModelConditioning`, `ControlNetApplyAdvanced`) where
221
+ the slot index of the incoming link selects the positive (0) or negative (1) branch.
222
+ """
223
+ if not _is_link(ref) or depth > _MAX_DEPTH:
224
+ return None
225
+ nid, slot = ref
226
+ node = graph.get(nid)
227
+ if node is None:
228
+ return None
229
+ ins = _inputs(node)
230
+
231
+ if "text" in ins or "prompt" in ins:
232
+ return nid
233
+ if "positive" in ins and "negative" in ins:
234
+ branch = "positive" if slot == 0 else "negative"
235
+ return _trace_conditioning_node(graph, ins.get(branch), depth + 1)
236
+ if "conditioning" in ins:
237
+ return _trace_conditioning_node(graph, ins["conditioning"], depth + 1)
238
+ return None
239
+
240
+
241
+ def _trace_conditioning(graph, ref, depth=0):
242
+ """Follow a conditioning link back to a text encoder; return the prompt text or None."""
243
+ nid = _trace_conditioning_node(graph, ref, depth)
244
+ if nid is None:
245
+ return None
246
+ ins = _inputs(graph[nid])
247
+ return _resolve_string(graph, ins["text"] if "text" in ins else ins["prompt"], depth + 1)
248
+
249
+
250
+ def conditioning_roles(graph: dict) -> dict:
251
+ """Map text-encoder node id → ``"positive"`` / ``"negative"``, as the parser resolves the sampler.
252
+
253
+ Exposes *which node* supplies each prompt (the same traversal `analyze` uses to read the text), so
254
+ `palimpsest` can label scrubbed prompts the way the parser reads them — by graph position, which is
255
+ unambiguous even when the positive and negative text happen to be identical or empty. Returns an
256
+ empty map when there's no resolvable sampler.
257
+ """
258
+ sink = _pick_sink(graph, [])
259
+ sampler = _find_sampler(graph, sink, []) if sink is not None else None
260
+ if sampler is None:
261
+ return {}
262
+ ins = _inputs(graph[sampler])
263
+ pos = _trace_conditioning_node(graph, ins.get("positive"))
264
+ neg = _trace_conditioning_node(graph, ins.get("negative"))
265
+ if pos is not None and pos == neg: # one node feeding both — ambiguous, don't guess
266
+ return {}
267
+ return {nid: role for nid, role in ((pos, "positive"), (neg, "negative")) if nid is not None}
268
+
269
+
270
+ def _walk_model(graph, ref):
271
+ """Walk the model link through the LoRA chain to the base loader.
272
+
273
+ Returns (model_name, [Lora, ...]). Collects every LoRA encountered (chains can be long), then
274
+ reads the base loader's model-name field (ckpt_name/gguf_name/unet_name).
275
+ """
276
+ loras = []
277
+ model_name = None
278
+ seen = set()
279
+ while _is_link(ref) and ref[0] not in seen:
280
+ nid = ref[0]
281
+ seen.add(nid)
282
+ node = graph.get(nid)
283
+ if node is None:
284
+ break
285
+ ins = _inputs(node)
286
+ if "lora_name" in ins:
287
+ loras.append(Lora(name=_resolve_string(graph, ins["lora_name"]),
288
+ strength=_resolve_value(graph, ins.get("strength_model"))))
289
+ ref = ins.get("model")
290
+ continue
291
+ base_field = next((k for k in ins if _is_base_loader_field(k)), None)
292
+ if base_field is not None:
293
+ model_name = _resolve_string(graph, ins[base_field])
294
+ break
295
+ ref = ins.get("model") # unknown model-passthrough: keep walking
296
+ return model_name, loras
297
+
298
+
299
+ _CLIP_NAME_FIELDS = ("clip_name", "clip_name1", "clip_name2", "clip_name3", "clip_name4")
300
+
301
+
302
+ def _walk_clip(graph, ref, depth=0):
303
+ """Follow a `clip` link back through passthrough nodes (LoRA, CLIPSetLastLayer, …) to the
304
+ text-encoder loader; return its clip_name file(s) in field order.
305
+
306
+ Empty when CLIP is baked into the checkpoint (CheckpointLoaderSimple has no clip_name field and
307
+ no `clip` input to keep following) — there's no separate text-encoder file in that case.
308
+ """
309
+ if not _is_link(ref) or depth > _MAX_DEPTH:
310
+ return []
311
+ node = graph.get(ref[0])
312
+ if node is None:
313
+ return []
314
+ ins = _inputs(node)
315
+ names = [n for n in (_resolve_string(graph, ins[f]) for f in _CLIP_NAME_FIELDS if f in ins) if n]
316
+ if names:
317
+ return names
318
+ return _walk_clip(graph, ins.get("clip"), depth + 1)
319
+
320
+
321
+ def _find_text_encoders(graph, sampler_id):
322
+ """The separate text-encoder file(s) feeding the conditioning, as Forge's "VAE/TE" modules.
323
+
324
+ Traces the sampler's positive conditioning back to its text-encoder node, then that node's `clip`
325
+ link back to the loader (DualCLIPLoader, CLIPLoader, …). Returns [] when CLIP comes baked in the
326
+ checkpoint, so a fully self-contained checkpoint emits no Module fields.
327
+ """
328
+ enc_id = _trace_conditioning_node(graph, _inputs(graph.get(sampler_id)).get("positive"))
329
+ if enc_id is None:
330
+ return []
331
+ return _walk_clip(graph, _inputs(graph[enc_id]).get("clip"))
332
+
333
+
334
+ def _find_vae(graph, sampler_id):
335
+ """Best-effort VAE name from the sampler's optional_vae / vae input."""
336
+ ins = _inputs(graph.get(sampler_id))
337
+ ref = ins.get("optional_vae") or ins.get("vae")
338
+ seen = set()
339
+ while _is_link(ref) and ref[0] not in seen:
340
+ nid = ref[0]
341
+ seen.add(nid)
342
+ node = graph.get(nid)
343
+ if node is None:
344
+ break
345
+ nins = _inputs(node)
346
+ if "vae_name" in nins:
347
+ return _resolve_string(graph, nins["vae_name"])
348
+ ref = nins.get("vae") or nins.get("samples")
349
+ return None
350
+
351
+
352
+ # --------------------------------------------------------------------------------
353
+ # Top-level
354
+
355
+ def analyze(graph: dict, width: Optional[int] = None, height: Optional[int] = None) -> Recipe:
356
+ """Reduce a ComfyUI `prompt` graph (+ PNG dimensions) to a `Recipe`."""
357
+ recipe = Recipe(width=width, height=height)
358
+
359
+ sink_id = _pick_sink(graph, recipe.warnings)
360
+ if sink_id is None:
361
+ return recipe
362
+ sampler_id = _find_sampler(graph, sink_id, recipe.warnings)
363
+ if sampler_id is None:
364
+ return recipe
365
+
366
+ sampler = graph[sampler_id]
367
+ ins = _inputs(sampler)
368
+ recipe.sampler_class = sampler.get("class_type")
369
+
370
+ # Scalars (resolving literals / Primitive* / Evaluate*).
371
+ seed_ref = ins.get("seed", ins.get("noise_seed"))
372
+ recipe.seed = _resolve_value(graph, seed_ref)
373
+ recipe.steps = _resolve_value(graph, ins.get("steps"))
374
+ recipe.cfg = _resolve_value(graph, ins.get("cfg"))
375
+ recipe.sampler_name = _resolve_value(graph, ins.get("sampler_name"))
376
+ recipe.scheduler = _resolve_value(graph, ins.get("scheduler"))
377
+ recipe.denoise = _resolve_value(graph, ins.get("denoise"))
378
+
379
+ # Prompts via the conditioning chain.
380
+ recipe.positive = _trace_conditioning(graph, ins.get("positive"))
381
+ recipe.negative = _trace_conditioning(graph, ins.get("negative"))
382
+
383
+ # Model + LoRAs, and VAE.
384
+ recipe.model, recipe.loras = _walk_model(graph, ins.get("model"))
385
+ recipe.vae = _find_vae(graph, sampler_id)
386
+ recipe.text_encoders = _find_text_encoders(graph, sampler_id)
387
+
388
+ if recipe.positive is None and recipe.negative is None:
389
+ recipe.warnings.append("no prompt text resolved")
390
+ if recipe.model is None:
391
+ recipe.warnings.append("model name unresolved")
392
+
393
+ return recipe
394
+
395
+
396
+ def format_recipe(recipe: Recipe) -> str:
397
+ """A readable multi-line dump of a Recipe, for `chandra show --recipe`."""
398
+ lines = []
399
+ lines.append(f"positive: {recipe.positive!r}")
400
+ lines.append(f"negative: {recipe.negative!r}")
401
+ size = f"{recipe.width}x{recipe.height}" if recipe.width and recipe.height else "?"
402
+ lines.append(f"size: {size}")
403
+ model_hash = f" [{recipe.model_hash}]" if recipe.model_hash else ""
404
+ lines.append(f"model: {recipe.model!r}{model_hash}")
405
+ for lora in recipe.loras:
406
+ lora_hash = f" [{lora.hash}]" if lora.hash else ""
407
+ lines.append(f" lora: {lora.name!r} (strength {lora.strength}){lora_hash}")
408
+ if recipe.vae:
409
+ vae_hash = f" [{recipe.vae_hash}]" if recipe.vae_hash else ""
410
+ lines.append(f"vae: {recipe.vae!r}{vae_hash}")
411
+ for te in recipe.text_encoders:
412
+ lines.append(f"te: {te!r}")
413
+ lines.append(f"sampler: {recipe.sampler_name!r} scheduler: {recipe.scheduler!r} "
414
+ f"({recipe.sampler_class})")
415
+ lines.append(f"steps: {recipe.steps} cfg: {recipe.cfg} denoise: {recipe.denoise} "
416
+ f"seed: {recipe.seed}")
417
+ for w in recipe.warnings:
418
+ lines.append(f" ! {w}")
419
+ return "\n".join(lines)
420
+
421
+
422
+ def format_description(recipe: Recipe) -> str:
423
+ """A clean, human-readable rendering of a Recipe, for embedding as an image description.
424
+
425
+ Unlike `format_recipe` (which uses `repr()` for exact, terminal-debugging fidelity), this keeps
426
+ the prompts as real text with real line breaks — so it reads naturally as the "Description"
427
+ caption a general image viewer (Pix, etc.) shows. Empty sections (no negative, no VAE, no LoRAs)
428
+ are omitted rather than shown blank.
429
+ """
430
+ lines = ["Positive:", recipe.positive]
431
+ if recipe.negative:
432
+ lines += ["", "Negative:", recipe.negative]
433
+ lines.append("")
434
+ if recipe.width and recipe.height:
435
+ lines.append(f"Size: {recipe.width}x{recipe.height}")
436
+ model_hash = f" [{recipe.model_hash}]" if recipe.model_hash else ""
437
+ lines.append(f"Model: {recipe.model}{model_hash}")
438
+ for lora in recipe.loras:
439
+ lora_hash = f" [{lora.hash}]" if lora.hash else ""
440
+ lines.append(f"LoRA: {lora.name} (strength {lora.strength}){lora_hash}")
441
+ if recipe.vae:
442
+ vae_hash = f" [{recipe.vae_hash}]" if recipe.vae_hash else ""
443
+ lines.append(f"VAE: {recipe.vae}{vae_hash}")
444
+ for te in recipe.text_encoders:
445
+ lines.append(f"Text enc: {te}")
446
+ lines.append(f"Sampler: {recipe.sampler_name} / {recipe.scheduler}")
447
+ lines.append(f"Steps: {recipe.steps} CFG: {recipe.cfg} Denoise: {recipe.denoise} "
448
+ f"Seed: {recipe.seed}")
449
+ for w in recipe.warnings:
450
+ lines.append(f"! {w}")
451
+ return "\n".join(lines)
chandra/cli.py ADDED
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env python
2
+ # PYTHON_ARGCOMPLETE_OK
3
+ """chandra — dispatcher for the imagegen-metadata-tools CLI toolbox.
4
+
5
+ Routes subcommands to the individual tools:
6
+
7
+ chandra search ... search prompts across a directory of images
8
+ chandra show ... print the A1111/CivitAI metadata for a ComfyUI image (read-only)
9
+ chandra inject ... write that metadata into the image(s)
10
+ chandra eject ... remove that metadata again (the inverse of inject)
11
+ chandra scrub ... strip a ComfyUI image to a de-branded, shareable skeleton
12
+
13
+ Each subtool module registers its subparser(s) (``add_subparser``) and sets an ``args.func`` handler,
14
+ so the dispatcher only has to wire them up and route. (The modules keep the names ``rosetta`` —
15
+ ``show``/``inject``/``eject`` —, ``concordance`` — ``search`` —, and ``palimpsest`` — ``scrub``; see
16
+ the README for the lineage.) Tab completion is provided by argcomplete when installed; it derives the
17
+ completion set from the live parser, so new subcommands appear in completion automatically.
18
+ """
19
+
20
+ import argparse
21
+ import sys
22
+
23
+ try:
24
+ import argcomplete
25
+ except ImportError: # completion is optional; the CLI works without it
26
+ argcomplete = None
27
+
28
+ from . import __version__
29
+ from . import concordance, palimpsest, rosetta
30
+
31
+ __all__ = ["build_parser", "main"]
32
+
33
+ # Subtool modules; subcommands appear in `chandra --help` in this order (search, show, inject, scrub).
34
+ _SUBTOOLS = (concordance, rosetta, palimpsest)
35
+
36
+
37
+ def build_parser() -> argparse.ArgumentParser:
38
+ """Construct the top-level `chandra` parser with one subparser per subtool."""
39
+ parser = argparse.ArgumentParser(
40
+ prog="chandra",
41
+ description="Tools for the metadata image generators embed in their output.",
42
+ )
43
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
44
+ # Not required: bare `chandra` prints the command list (see main) rather than erroring.
45
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
46
+ for tool in _SUBTOOLS:
47
+ tool.add_subparser(subparsers)
48
+ return parser
49
+
50
+
51
+ def main(argv=None) -> int:
52
+ """Entry point for the `chandra` console script.
53
+
54
+ `argv` defaults to `sys.argv[1:]`; pass an explicit list to drive the dispatcher from tests.
55
+ Bare `chandra` (no subcommand) prints the help, which lists the available commands.
56
+ """
57
+ parser = build_parser()
58
+ if argcomplete is not None:
59
+ argcomplete.autocomplete(parser)
60
+ args = parser.parse_args(argv)
61
+ if not getattr(args, "func", None):
62
+ parser.print_help()
63
+ return 0
64
+ return args.func(args)
65
+
66
+
67
+ if __name__ == "__main__":
68
+ sys.exit(main())