baghban 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.
baghban/__init__.py ADDED
@@ -0,0 +1,23 @@
1
+ """baghban (باغبان, bağban: gardener) — tend Bonsai workflows from Python.
2
+
3
+ Check, draw and compare Bonsai (bonsai-rx.org) workflow files on any
4
+ machine, macOS included, without Windows or .NET. Standard library only.
5
+
6
+ import baghban
7
+ doc = baghban.load("rig/foraging.bonsai")
8
+ print(baghban.check(doc).summary())
9
+ """
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ from .model import Document, Node, Workflow, TypeRef, Include, WorkflowLoadError, load, loads
14
+ from .checks import Finding, Report, check, FINDINGS
15
+ from .diff import diff, WorkflowDiff
16
+ from .info import dependencies, parameters, fingerprint, manifest_fields
17
+ from .render import outline, mermaid, dot, html
18
+
19
+ __all__ = [
20
+ "Document", "Node", "Workflow", "TypeRef", "Include", "WorkflowLoadError", "load", "loads",
21
+ "Finding", "Report", "check", "FINDINGS", "diff", "WorkflowDiff", "dependencies",
22
+ "parameters", "fingerprint", "manifest_fields", "outline", "mermaid", "dot", "html",
23
+ ]
baghban/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
baghban/checks.py ADDED
@@ -0,0 +1,384 @@
1
+ """Static checks: problems a Bonsai workflow has before it is ever run.
2
+
3
+ Each check reports a problem only when the XML is enough to be sure of it.
4
+ When it is not (an include that cannot be opened, a file name computed from
5
+ data at run time), the check stays quiet and says what it could not see in
6
+ ``Report.skipped``, instead of guessing.
7
+
8
+ Behaviour that the checks rely on, verified in the Bonsai source:
9
+
10
+ * ``CsvWriter`` and ``FileSink`` (Bonsai.System/IO) open their file when they
11
+ are *subscribed*, check ``File.Exists(path) && !Overwrite`` (and ``!Append``
12
+ for CsvWriter), then create it. ``PathSuffix`` is ``None``, ``FileCount`` or
13
+ ``Timestamp`` (ISO 8601 with 100 ns resolution).
14
+ * ``SelectMany``, ``CreateObservable`` and ``WindowWorkflow`` subscribe their
15
+ nested workflow once per input element, so a writer inside one is opened
16
+ once per element: typically once per trial.
17
+ * Declaring two subjects with the same name in one build context raises
18
+ "A variable with the specified name already exists" (BuildContext.cs).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ from dataclasses import dataclass, field
25
+ from typing import Optional
26
+
27
+ from .model import Document, Node, Workflow
28
+
29
+ SEVERITIES = ("error", "warning", "info")
30
+ PIPE_PREFIX = "\\\\.\\pipe\\"
31
+
32
+ # code -> (severity, one-line description); the README findings table mirrors this.
33
+ FINDINGS = {
34
+ "SILENT_OVERWRITE": ("error", "a writer replaces data from an earlier run or trial without warning"),
35
+ "FAILS_ON_REPEAT": ("error", "a writer inside a per-trial loop raises 'file already exists' on the second trial"),
36
+ "DUPLICATE_OUTPUT": ("error", "two writers write to the same file"),
37
+ "DANGLING_SUBJECT": ("error", "a SubscribeSubject/MulticastSubject names a subject that is not declared in scope"),
38
+ "DUPLICATE_SUBJECT": ("error", "two subjects with the same name are declared in one scope"),
39
+ "MISSING_INCLUDE": ("error", "an IncludeWorkflow points at a file that does not exist"),
40
+ "RECURSIVE_INCLUDE": ("error", "workflows include each other in a cycle"),
41
+ "INVALID_INCLUDE": ("error", "an included file is not a readable workflow"),
42
+ "DISABLED_WRITER": ("warning", "a writer is disabled, so nothing is recorded to its file"),
43
+ "APPENDS_ACROSS_RUNS": ("info", "every run appends to the same file"),
44
+ "UNUSED_SUBJECT": ("info", "a subject is declared but nothing subscribes to it"),
45
+ }
46
+
47
+
48
+ @dataclass
49
+ class Finding:
50
+ code: str
51
+ severity: str
52
+ message: str
53
+ location: str
54
+ file: Optional[str] = None
55
+ node: tuple = ()
56
+ hint: str = ""
57
+
58
+ def to_dict(self) -> dict:
59
+ return {"code": self.code, "severity": self.severity, "message": self.message,
60
+ "location": self.location, "file": self.file, "node": list(self.node),
61
+ "hint": self.hint}
62
+
63
+
64
+ @dataclass
65
+ class Report:
66
+ path: Optional[str]
67
+ findings: list = field(default_factory=list)
68
+ skipped: list = field(default_factory=list)
69
+ n_nodes: int = 0
70
+
71
+ def by_severity(self, severity: str) -> list:
72
+ return [f for f in self.findings if f.severity == severity]
73
+
74
+ @property
75
+ def errors(self) -> list:
76
+ return self.by_severity("error")
77
+
78
+ @property
79
+ def warnings(self) -> list:
80
+ return self.by_severity("warning")
81
+
82
+ def codes(self) -> list:
83
+ return [f.code for f in self.findings]
84
+
85
+ def exit_code(self, strict: bool = False) -> int:
86
+ if self.errors or (strict and self.warnings):
87
+ return 1
88
+ return 0
89
+
90
+ def summary(self, show_info: bool = True) -> str:
91
+ lines = []
92
+ for f in self.findings:
93
+ if f.severity == "info" and not show_info:
94
+ continue
95
+ lines.append(f"[{f.severity}] {f.code} {f.location}")
96
+ where = f" in {f.file}" if f.file and f.file != self.path else ""
97
+ if where:
98
+ lines.append(where)
99
+ for text in _wrap(f.message):
100
+ lines.append(f" {text}")
101
+ if f.hint:
102
+ for text in _wrap("fix: " + f.hint):
103
+ lines.append(f" {text}")
104
+ lines.append("")
105
+ counts = [f"{len(self.by_severity(s))} {s}{'s' if len(self.by_severity(s)) != 1 else ''}"
106
+ for s in SEVERITIES]
107
+ if not self.findings:
108
+ lines.append(f"No findings in {self.n_nodes} nodes.")
109
+ else:
110
+ lines.append(", ".join(counts) + f" in {self.n_nodes} nodes.")
111
+ for note in self.skipped:
112
+ lines.append(f"not checked: {note}")
113
+ return "\n".join(lines)
114
+
115
+ def to_dict(self) -> dict:
116
+ return {"path": self.path, "nodes": self.n_nodes,
117
+ "findings": [f.to_dict() for f in self.findings],
118
+ "skipped": list(self.skipped)}
119
+
120
+ def to_json(self) -> str:
121
+ return json.dumps(self.to_dict(), indent=2)
122
+
123
+
124
+ def _wrap(text: str, width: int = 88) -> list:
125
+ import textwrap
126
+ return textwrap.wrap(text, width) or [""]
127
+
128
+
129
+ def _bool(value: Optional[str]) -> bool:
130
+ return (value or "").strip().lower() == "true"
131
+
132
+
133
+ def _finding(code: str, node: Node, message: str, hint: str = "",
134
+ severity: Optional[str] = None) -> Finding:
135
+ return Finding(code=code, severity=severity or FINDINGS[code][0], message=message,
136
+ location=node.location(), file=str(node.file) if node.file else None,
137
+ node=node.id, hint=hint)
138
+
139
+
140
+ # --------------------------------------------------------------------------- includes
141
+
142
+ def check_includes(doc: Document, report: Report) -> None:
143
+ for node in doc.walk():
144
+ if not node.is_include or node.include is None or node.is_effectively_disabled():
145
+ continue
146
+ inc = node.include
147
+ if inc.status == "missing":
148
+ report.findings.append(_finding(
149
+ "MISSING_INCLUDE", node,
150
+ f"IncludeWorkflow Path='{inc.path}' cannot be found ({inc.note}). "
151
+ "Bonsai will fail to build the workflow.",
152
+ hint="Include paths are resolved relative to the folder of the top-level "
153
+ "workflow (the editor's working directory), also for nested includes."))
154
+ elif inc.status == "recursive":
155
+ report.findings.append(_finding(
156
+ "RECURSIVE_INCLUDE", node, f"{inc.note}. Bonsai cannot build a workflow "
157
+ "that includes itself."))
158
+ elif inc.status == "invalid":
159
+ report.findings.append(_finding("INVALID_INCLUDE", node, inc.note))
160
+ elif inc.status == "unresolved":
161
+ report.skipped.append(f"{node.location()}: {inc.note}")
162
+
163
+
164
+ # --------------------------------------------------------------------------- subjects
165
+
166
+ def _enabled(node: Node) -> bool:
167
+ return not node.is_effectively_disabled()
168
+
169
+
170
+ def check_subjects(doc: Document, report: Report) -> None:
171
+ declared: dict[int, dict[str, list]] = {}
172
+ scopes: dict[int, Workflow] = {}
173
+ opaque: set = set()
174
+
175
+ for wf in doc.workflow.workflows():
176
+ scope = wf.scope()
177
+ scopes[id(scope)] = scope
178
+ declared.setdefault(id(scope), {})
179
+ for node in wf.nodes:
180
+ if not _enabled(node):
181
+ continue
182
+ if node.is_subject_declaration and node.name:
183
+ declared[id(scope)].setdefault(node.name, []).append(node)
184
+ if node.is_include and node.include is not None and node.include.status != "ok":
185
+ opaque.add(id(scope))
186
+
187
+ def chain(scope: Workflow):
188
+ s: Optional[Workflow] = scope
189
+ while s is not None:
190
+ yield s
191
+ s = s.parent_scope()
192
+
193
+ # scopes that contain (at any depth) something baghban could not read
194
+ opaque_within: set = set()
195
+ for sid in opaque:
196
+ for s in chain(scopes[sid]):
197
+ opaque_within.add(id(s))
198
+
199
+ for sid, names in declared.items():
200
+ for name, nodes in names.items():
201
+ if len(nodes) > 1:
202
+ first = nodes[0]
203
+ for dup in nodes[1:]:
204
+ report.findings.append(_finding(
205
+ "DUPLICATE_SUBJECT", dup,
206
+ f"Subject '{name}' is already declared in this scope by "
207
+ f"{first.location()}. Bonsai refuses to build the workflow. "
208
+ "Groups and included workflows share their parent's scope, so "
209
+ "declarations inside them count as declarations next to them.",
210
+ hint="Rename one of them, or move one inside a nested workflow such as "
211
+ "SelectMany if it is meant to be local."))
212
+
213
+ used: set = set()
214
+ skipped_names: set = set()
215
+ for node in doc.walk():
216
+ if not node.is_subject_use or not _enabled(node):
217
+ continue
218
+ scope = node.parent.scope()
219
+ if not node.name:
220
+ report.findings.append(_finding(
221
+ "DANGLING_SUBJECT", node, f"{node.type.name} has no subject Name."))
222
+ continue
223
+ target = None
224
+ blind = False
225
+ for s in chain(scope):
226
+ if id(s) in opaque:
227
+ blind = True
228
+ hits = declared.get(id(s), {}).get(node.name)
229
+ if hits:
230
+ target = hits[0]
231
+ break
232
+ if target is not None:
233
+ used.add(id(target))
234
+ elif blind:
235
+ key = (node.name, id(scope))
236
+ if key not in skipped_names:
237
+ skipped_names.add(key)
238
+ report.skipped.append(f"subject '{node.name}' used at {node.location()} may be "
239
+ "declared in an include that could not be read")
240
+ else:
241
+ hidden = [d for names in declared.values() for d in names.get(node.name, [])]
242
+ if hidden:
243
+ why = (f" A subject with this name is declared at {hidden[0].location()}, but "
244
+ "that is inside a nested workflow, whose declarations are not visible "
245
+ "outside it (groups are the exception).")
246
+ hint = "Move the declaration out of the nested workflow, or into a group."
247
+ else:
248
+ why = ""
249
+ hint = _near_miss(node.name, declared)
250
+ report.findings.append(_finding(
251
+ "DANGLING_SUBJECT", node,
252
+ f"No subject named '{node.name}' is declared in this scope or any enclosing "
253
+ f"one, so Bonsai will fail to build the workflow.{why}", hint=hint))
254
+
255
+ for sid, names in declared.items():
256
+ if sid in opaque_within:
257
+ continue
258
+ for name, nodes in names.items():
259
+ if len(nodes) > 1:
260
+ continue # already reported as DUPLICATE_SUBJECT
261
+ for decl in nodes:
262
+ if id(decl) not in used and not decl.type.is_core("ResourceSubject"):
263
+ report.findings.append(_finding(
264
+ "UNUSED_SUBJECT", decl,
265
+ f"Subject '{name}' is declared but nothing subscribes to it."))
266
+
267
+
268
+ def _near_miss(name: str, declared: dict) -> str:
269
+ import difflib
270
+ names = {n for scope in declared.values() for n in scope}
271
+ close = difflib.get_close_matches(name, sorted(names), n=1, cutoff=0.75)
272
+ return f"Did you mean '{close[0]}'?" if close else ""
273
+
274
+
275
+ # --------------------------------------------------------------------------- writers
276
+
277
+ def _loop_name(node: Node) -> str:
278
+ return f"{node.type.name}" + (f" '{node.name}'" if node.name else "")
279
+
280
+
281
+ def check_writers(doc: Document, report: Report) -> None:
282
+ outputs: dict[str, list] = {}
283
+ for node in doc.walk():
284
+ if not node.is_writer:
285
+ continue
286
+ prop = node.filename_property
287
+ fname = node.properties.get(prop, "") or ""
288
+
289
+ if node.is_effectively_disabled():
290
+ why = "is disabled" if node.disabled else "sits inside a disabled node"
291
+ report.findings.append(_finding(
292
+ "DISABLED_WRITER", node,
293
+ f"This {node.type.name} {why}, so nothing will be written to "
294
+ f"'{fname or '(no file name)'}'. Disabling a writer while debugging and "
295
+ "forgetting to re-enable it loses whole sessions without an error.",
296
+ hint="Re-enable it, or delete it if the data is not needed."))
297
+ continue
298
+
299
+ if prop in node.mapped():
300
+ report.skipped.append(f"{node.location()}: {prop} is assigned from data at "
301
+ "run time")
302
+ continue
303
+ if not fname or fname.startswith(PIPE_PREFIX):
304
+ continue
305
+
306
+ suffix = (node.properties.get("Suffix") or "None").strip()
307
+ overwrite = _bool(node.properties.get("Overwrite"))
308
+ append = _bool(node.properties.get("Append"))
309
+ externalized = prop in node.externalized()
310
+ loop = node.repeating_container()
311
+
312
+ if suffix == "None":
313
+ outputs.setdefault(_normalise(fname), []).append(node)
314
+ if append:
315
+ header = (" With IncludeHeader=True the header row is written again in the "
316
+ "middle of the file each time." if _bool(node.properties.get("IncludeHeader")) else "")
317
+ report.findings.append(_finding(
318
+ "APPENDS_ACROSS_RUNS", node,
319
+ f"Append=True with Suffix=None: every run appends to '{fname}', so rows "
320
+ f"from different sessions accumulate in one file.{header}",
321
+ hint="Fine if intended; otherwise use Suffix=Timestamp."))
322
+ elif overwrite and loop is not None:
323
+ report.findings.append(_finding(
324
+ "SILENT_OVERWRITE", node,
325
+ f"Overwrite=True with Suffix=None inside {_loop_name(loop)}: the writer is "
326
+ f"opened again for every element (e.g. every trial), and each time it "
327
+ f"replaces '{fname}'. Only the last element's data survives the session.",
328
+ hint="Use Suffix=Timestamp or FileCount to keep one file per element, or "
329
+ "move the writer after the SelectMany to collect all elements in one "
330
+ "file."))
331
+ elif overwrite:
332
+ severity = "warning" if externalized else "error"
333
+ extra = (f" {prop} is externalized, so this is only safe if the launcher sets a "
334
+ "new value on every run." if externalized else "")
335
+ report.findings.append(_finding(
336
+ "SILENT_OVERWRITE", node,
337
+ f"Overwrite=True with Suffix=None: each run replaces '{fname}' from the "
338
+ f"previous session, without an error.{extra}",
339
+ hint="Use Suffix=Timestamp (one file per run), or Overwrite=False so a "
340
+ "second run stops instead of destroying data.",
341
+ severity=severity))
342
+ elif loop is not None:
343
+ report.findings.append(_finding(
344
+ "FAILS_ON_REPEAT", node,
345
+ f"Suffix=None and Overwrite=False inside {_loop_name(loop)}: the first "
346
+ f"element creates '{fname}', the second finds it already exists and "
347
+ "raises an IOException, which terminates the workflow mid-session.",
348
+ hint="Use Suffix=Timestamp or FileCount, or move the writer after the "
349
+ "SelectMany."))
350
+
351
+ for key, nodes in outputs.items():
352
+ if len(nodes) < 2:
353
+ continue
354
+ others = "; ".join(n.location() for n in nodes[:-1])
355
+ for dup in nodes[1:]:
356
+ report.findings.append(_finding(
357
+ "DUPLICATE_OUTPUT", dup,
358
+ f"Another writer also writes '{dup.properties[dup.filename_property]}' "
359
+ f"({others}). Two writers cannot hold the same file: one of them fails, or "
360
+ "they overwrite each other. This is common when a module containing a "
361
+ "writer is included twice without giving each copy its own file name.",
362
+ hint="Give each writer its own file name; for included modules, externalize "
363
+ "FileName and set it on each IncludeWorkflow."))
364
+
365
+
366
+ def _normalise(fname: str) -> str:
367
+ # Bonsai rigs run on Windows, where paths are case-insensitive.
368
+ return fname.replace("\\", "/").strip().casefold()
369
+
370
+
371
+ # --------------------------------------------------------------------------- entry point
372
+
373
+ CHECKS = (check_includes, check_subjects, check_writers)
374
+
375
+
376
+ def check(doc: Document) -> Report:
377
+ """Run every check on a loaded document."""
378
+ report = Report(path=str(doc.path) if doc.path else None,
379
+ n_nodes=sum(1 for _ in doc.walk()))
380
+ for fn in CHECKS:
381
+ fn(doc, report)
382
+ order = {s: i for i, s in enumerate(SEVERITIES)}
383
+ report.findings.sort(key=lambda f: (order[f.severity], f.node))
384
+ return report
baghban/cli.py ADDED
@@ -0,0 +1,211 @@
1
+ """baghban command line.
2
+
3
+ baghban check WORKFLOW|DIR ... problems found before the rig runs (exit 1 on errors)
4
+ baghban show WORKFLOW the workflow as a tree
5
+ baghban render WORKFLOW -f html draw it (html | mermaid | dot)
6
+ baghban diff A B what changed, by meaning rather than by line
7
+ baghban deps WORKFLOW assemblies/packages and includes it needs
8
+ baghban params WORKFLOW externalized properties and their values
9
+ baghban fingerprint WORKFLOW content hash, stable across re-saves
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from . import __version__
20
+ from .checks import check
21
+ from .diff import diff
22
+ from .info import (dependencies, fingerprint, format_dependencies, format_parameters,
23
+ manifest_fields, parameters)
24
+ from .model import WorkflowLoadError, load
25
+ from .render import dot, html, mermaid, outline
26
+
27
+
28
+ def _resource_roots(values) -> dict:
29
+ roots = {}
30
+ for item in values or []:
31
+ name, sep, folder = item.partition("=")
32
+ if not sep:
33
+ raise SystemExit(f"--resource-root expects ASSEMBLY=DIR, got '{item}'")
34
+ roots[name] = folder
35
+ return roots
36
+
37
+
38
+ def _collect(paths) -> list:
39
+ files = []
40
+ for p in map(Path, paths):
41
+ if p.is_dir():
42
+ for f in sorted(p.rglob("*.bonsai")):
43
+ if any(part.startswith(".") for part in f.relative_to(p).parts[:-1]):
44
+ continue # skip .bonsai environment folders and hidden dirs
45
+ files.append(f)
46
+ else:
47
+ files.append(p)
48
+ return files
49
+
50
+
51
+ def _load(path, args):
52
+ try:
53
+ return load(path, resource_roots=_resource_roots(getattr(args, "resource_root", None)))
54
+ except (WorkflowLoadError, OSError) as exc:
55
+ raise SystemExit(f"baghban: {exc}")
56
+
57
+
58
+ def cmd_check(args) -> int:
59
+ files = _collect(args.paths)
60
+ if not files:
61
+ print("baghban: no .bonsai files found", file=sys.stderr)
62
+ return 2
63
+ docs, failures = [], []
64
+ for f in files:
65
+ try:
66
+ docs.append(load(f, resource_roots=_resource_roots(args.resource_root)))
67
+ except (WorkflowLoadError, OSError) as exc:
68
+ failures.append(str(exc))
69
+
70
+ # A file that another checked file includes is checked through its includer,
71
+ # where the subjects and parameters it relies on are defined.
72
+ included = {p for d in docs for p in d.included_files}
73
+ roots = [d for d in docs if d.path.resolve() not in included]
74
+ via_includer = len(docs) - len(roots)
75
+
76
+ reports = [check(d) for d in roots]
77
+ code = max([r.exit_code(args.strict) for r in reports] + [1 if failures else 0])
78
+
79
+ if args.json:
80
+ print(json.dumps({"reports": [r.to_dict() for r in reports], "unreadable": failures},
81
+ indent=2))
82
+ return code
83
+
84
+ for i, r in enumerate(reports):
85
+ if len(reports) > 1:
86
+ print(("\n" if i else "") + f"== {r.path}")
87
+ print(r.summary(show_info=not args.no_info))
88
+ for msg in failures:
89
+ print(f"unreadable: {msg}")
90
+ if via_includer:
91
+ print(f"\n{via_includer} file(s) checked through the workflows that include them.")
92
+ return code
93
+
94
+
95
+ def cmd_show(args) -> int:
96
+ print(outline(_load(args.workflow, args)))
97
+ return 0
98
+
99
+
100
+ def cmd_render(args) -> int:
101
+ doc = _load(args.workflow, args)
102
+ fmt = args.format
103
+ if fmt == "html":
104
+ text = html(doc, check(doc))
105
+ elif fmt == "dot":
106
+ text = dot(doc, check(doc).findings)
107
+ else:
108
+ text = mermaid(doc, check(doc).findings)
109
+ out = args.output
110
+ if out is None and fmt == "html":
111
+ out = Path(args.workflow).with_suffix(".html").name
112
+ if out:
113
+ Path(out).write_text(text, encoding="utf-8")
114
+ print(f"wrote {out}")
115
+ else:
116
+ print(text)
117
+ return 0
118
+
119
+
120
+ def cmd_diff(args) -> int:
121
+ result = diff(_load(args.a, args), _load(args.b, args))
122
+ print(json.dumps(result.to_dict(), indent=2, default=str) if args.json else result.summary())
123
+ return 0 if result.identical else 1
124
+
125
+
126
+ def cmd_deps(args) -> int:
127
+ deps = dependencies(_load(args.workflow, args))
128
+ print(json.dumps(deps, indent=2) if args.json else format_dependencies(deps))
129
+ return 0
130
+
131
+
132
+ def cmd_params(args) -> int:
133
+ params = parameters(_load(args.workflow, args))
134
+ print(json.dumps(params, indent=2) if args.json else format_parameters(params))
135
+ return 0
136
+
137
+
138
+ def cmd_fingerprint(args) -> int:
139
+ doc = _load(args.workflow, args)
140
+ if args.fields:
141
+ print(json.dumps(manifest_fields(doc), indent=2))
142
+ else:
143
+ print(f"{fingerprint(doc)} {args.workflow}")
144
+ return 0
145
+
146
+
147
+ def build_parser() -> argparse.ArgumentParser:
148
+ p = argparse.ArgumentParser(
149
+ prog="baghban",
150
+ description="Check, draw and compare Bonsai workflows without Windows or .NET.")
151
+ p.add_argument("--version", action="version", version=f"baghban {__version__}")
152
+ sub = p.add_subparsers(dest="command", required=True)
153
+
154
+ def with_roots(sp):
155
+ sp.add_argument("--resource-root", action="append", metavar="ASSEMBLY=DIR",
156
+ help="folder holding the source of an assembly, to follow "
157
+ "embedded-resource includes (repeatable)")
158
+ return sp
159
+
160
+ c = with_roots(sub.add_parser("check", help="find problems before the rig runs"))
161
+ c.add_argument("paths", nargs="+", help=".bonsai files or folders")
162
+ c.add_argument("--json", action="store_true")
163
+ c.add_argument("--strict", action="store_true", help="exit 1 on warnings too")
164
+ c.add_argument("--no-info", action="store_true", help="hide info-level findings")
165
+ c.set_defaults(func=cmd_check)
166
+
167
+ s = with_roots(sub.add_parser("show", help="print the workflow as a tree"))
168
+ s.add_argument("workflow")
169
+ s.set_defaults(func=cmd_show)
170
+
171
+ r = with_roots(sub.add_parser("render", help="draw the workflow"))
172
+ r.add_argument("workflow")
173
+ r.add_argument("-f", "--format", choices=("html", "mermaid", "dot"), default="html")
174
+ r.add_argument("-o", "--output", help="output file (html defaults to WORKFLOW.html)")
175
+ r.set_defaults(func=cmd_render)
176
+
177
+ d = with_roots(sub.add_parser("diff", help="semantic diff of two workflows"))
178
+ d.add_argument("a")
179
+ d.add_argument("b")
180
+ d.add_argument("--json", action="store_true")
181
+ d.set_defaults(func=cmd_diff)
182
+
183
+ dp = with_roots(sub.add_parser("deps", help="assemblies and includes the workflow needs"))
184
+ dp.add_argument("workflow")
185
+ dp.add_argument("--json", action="store_true")
186
+ dp.set_defaults(func=cmd_deps)
187
+
188
+ pa = with_roots(sub.add_parser("params", help="externalized properties and values"))
189
+ pa.add_argument("workflow")
190
+ pa.add_argument("--json", action="store_true")
191
+ pa.set_defaults(func=cmd_params)
192
+
193
+ f = with_roots(sub.add_parser("fingerprint", help="content hash stable across re-saves"))
194
+ f.add_argument("workflow")
195
+ f.add_argument("--fields", action="store_true",
196
+ help="print provenance fields (for daftar) instead of the hash")
197
+ f.set_defaults(func=cmd_fingerprint)
198
+ return p
199
+
200
+
201
+ def main(argv=None) -> int:
202
+ args = build_parser().parse_args(argv)
203
+ try:
204
+ return args.func(args)
205
+ except BrokenPipeError: # e.g. `baghban diff a b | head`
206
+ sys.stderr.close()
207
+ return 0
208
+
209
+
210
+ if __name__ == "__main__": # pragma: no cover
211
+ sys.exit(main())