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 +23 -0
- baghban/__main__.py +5 -0
- baghban/checks.py +384 -0
- baghban/cli.py +211 -0
- baghban/diff.py +169 -0
- baghban/info.py +156 -0
- baghban/model.py +600 -0
- baghban/render.py +207 -0
- baghban-0.1.0.dist-info/METADATA +342 -0
- baghban-0.1.0.dist-info/RECORD +15 -0
- baghban-0.1.0.dist-info/WHEEL +5 -0
- baghban-0.1.0.dist-info/entry_points.txt +2 -0
- baghban-0.1.0.dist-info/licenses/LICENSE +202 -0
- baghban-0.1.0.dist-info/licenses/NOTICE +11 -0
- baghban-0.1.0.dist-info/top_level.txt +1 -0
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
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())
|