cmdchain 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.
- clichain/__init__.py +29 -0
- clichain/checks.py +177 -0
- clichain/cli.py +151 -0
- clichain/core.py +1599 -0
- clichain/py.typed +0 -0
- cmdchain-0.1.0.dist-info/METADATA +22 -0
- cmdchain-0.1.0.dist-info/RECORD +11 -0
- cmdchain-0.1.0.dist-info/WHEEL +4 -0
- cmdchain-0.1.0.dist-info/entry_points.txt +2 -0
- cmdchain-0.1.0.dist-info/licenses/LICENSE-APACHE +190 -0
- cmdchain-0.1.0.dist-info/licenses/LICENSE-MIT +21 -0
clichain/core.py
ADDED
|
@@ -0,0 +1,1599 @@
|
|
|
1
|
+
"""Core primitives: Cmd, Pipeline, Result, tool()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shlex
|
|
9
|
+
import shutil
|
|
10
|
+
import signal
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import IO
|
|
20
|
+
|
|
21
|
+
from clichain.checks import (
|
|
22
|
+
CheckResult,
|
|
23
|
+
OnFail,
|
|
24
|
+
_detect_version,
|
|
25
|
+
binary_check,
|
|
26
|
+
run_checks,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# -- Profiling ----------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class MeterStats:
|
|
34
|
+
label: str = ""
|
|
35
|
+
bytes: int = 0
|
|
36
|
+
lines: int = 0
|
|
37
|
+
elapsed: float = 0.0
|
|
38
|
+
bytes_per_sec: float = 0.0
|
|
39
|
+
lines_per_sec: float = 0.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class StepProfile:
|
|
44
|
+
name: str
|
|
45
|
+
elapsed: float = 0.0
|
|
46
|
+
lines_in: int = 0
|
|
47
|
+
lines_out: int = 0
|
|
48
|
+
spawns: int = 0
|
|
49
|
+
bytes_in: int | None = None
|
|
50
|
+
bytes_out: int | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _count_lines(text: str) -> int:
|
|
54
|
+
stripped = text.strip()
|
|
55
|
+
if not stripped:
|
|
56
|
+
return 0
|
|
57
|
+
return len(stripped.splitlines())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _step_name(step: Step) -> str:
|
|
61
|
+
if isinstance(step, CmdStep):
|
|
62
|
+
return shlex.join([step.binary, *step.args])
|
|
63
|
+
if isinstance(step, FilterStep):
|
|
64
|
+
return "filter"
|
|
65
|
+
if isinstance(step, EachStep):
|
|
66
|
+
w = f" workers={step.workers}" if step.workers > 1 else ""
|
|
67
|
+
return f"each{w}"
|
|
68
|
+
if isinstance(step, PeekStep):
|
|
69
|
+
return f"peek({step.label})" if step.label else "peek"
|
|
70
|
+
if isinstance(step, CollectStep):
|
|
71
|
+
return "collect"
|
|
72
|
+
if isinstance(step, CaptureStep):
|
|
73
|
+
return f"capture({step.name})"
|
|
74
|
+
if isinstance(step, RedirectStep):
|
|
75
|
+
targets = []
|
|
76
|
+
if step.stdout:
|
|
77
|
+
targets.append(f">{step.stdout}")
|
|
78
|
+
if step.stderr:
|
|
79
|
+
targets.append(f"2>{step.stderr}")
|
|
80
|
+
return f"redirect {' '.join(targets)}"
|
|
81
|
+
if isinstance(step, FeedStep):
|
|
82
|
+
return f"feed ({len(step.data)} bytes)"
|
|
83
|
+
if isinstance(step, FromFileStep):
|
|
84
|
+
return f"< {step.path}"
|
|
85
|
+
if isinstance(step, MeterStep):
|
|
86
|
+
return f"meter({step.label})" if step.label else "meter"
|
|
87
|
+
return "unknown"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _group_name(group: list[Step]) -> str:
|
|
91
|
+
return " | ".join(_step_name(s) for s in group)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# -- Result -------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class Result:
|
|
99
|
+
stdout: str = ""
|
|
100
|
+
stderr: str = ""
|
|
101
|
+
returncode: int = 0
|
|
102
|
+
checks: list[CheckResult] = field(default_factory=list)
|
|
103
|
+
profile: list[StepProfile] = field(default_factory=list)
|
|
104
|
+
elapsed: float = 0.0
|
|
105
|
+
_steps: list[Step] = field(default_factory=list, repr=False)
|
|
106
|
+
_timestamp: str = ""
|
|
107
|
+
_failed_step: int | None = field(default=None, repr=False)
|
|
108
|
+
_source_location: str = field(default="", repr=False)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def ok(self) -> bool:
|
|
112
|
+
return self.returncode == 0
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def lines(self) -> list[str]:
|
|
116
|
+
stripped = self.stdout.strip()
|
|
117
|
+
if not stripped:
|
|
118
|
+
return []
|
|
119
|
+
return stripped.splitlines()
|
|
120
|
+
|
|
121
|
+
def explain(self) -> None:
|
|
122
|
+
"""Print detailed error explanation with execution trace."""
|
|
123
|
+
if self.ok:
|
|
124
|
+
_emit("no errors")
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
code = _error_code(self.returncode)
|
|
128
|
+
short = _explain_exit(self.returncode)
|
|
129
|
+
|
|
130
|
+
_emit(f"error[{code}]: {short}")
|
|
131
|
+
|
|
132
|
+
if self._failed_step is not None and self._steps:
|
|
133
|
+
_emit("")
|
|
134
|
+
_emit(" execution trace:")
|
|
135
|
+
for i, step in enumerate(self._steps):
|
|
136
|
+
is_failed = i == self._failed_step
|
|
137
|
+
line = _step_trace_line(i, step, failed=is_failed)
|
|
138
|
+
_emit(line)
|
|
139
|
+
|
|
140
|
+
if self._source_location:
|
|
141
|
+
_emit("")
|
|
142
|
+
_emit(f" source: {self._source_location}")
|
|
143
|
+
|
|
144
|
+
detail = _ERROR_DETAIL.get(code, "")
|
|
145
|
+
if detail:
|
|
146
|
+
_emit("")
|
|
147
|
+
_emit(f" {detail}")
|
|
148
|
+
|
|
149
|
+
def report(self) -> None:
|
|
150
|
+
W = 72
|
|
151
|
+
data = self.sbom()
|
|
152
|
+
gen = data["generator"]
|
|
153
|
+
env = data["environment"]
|
|
154
|
+
status = "OK" if self.ok else f"FAILED {_explain_exit(self.returncode)}"
|
|
155
|
+
|
|
156
|
+
# Header
|
|
157
|
+
sep = "=" * W
|
|
158
|
+
div = "-" * W
|
|
159
|
+
v, py = gen["version"], gen["python"]
|
|
160
|
+
plat = f"{env['os']} {env['os_version']} {env['arch']}"
|
|
161
|
+
_emit(sep)
|
|
162
|
+
_emit(f" clichain {v} | python {py} | {plat}")
|
|
163
|
+
_emit(f" {env['hostname']} {env['cwd']}")
|
|
164
|
+
_emit(f" {data['timestamp']}")
|
|
165
|
+
_emit(f" {status} {data['elapsed']}s")
|
|
166
|
+
_emit(sep)
|
|
167
|
+
|
|
168
|
+
# Tools
|
|
169
|
+
_emit(f" {'tool':<20s} {'version':>10s} path")
|
|
170
|
+
_emit(div)
|
|
171
|
+
for t in data["tools"]:
|
|
172
|
+
ver = t.get("version", "-")
|
|
173
|
+
path = t.get("path", "not found")
|
|
174
|
+
_emit(f" {t['name']:<20s} {ver:>10s} {path}")
|
|
175
|
+
|
|
176
|
+
# Files
|
|
177
|
+
has_files = "files_read" in data or "files_written" in data
|
|
178
|
+
if has_files:
|
|
179
|
+
_emit(div)
|
|
180
|
+
for f in data.get("files_read", []):
|
|
181
|
+
_emit(f" < {f}")
|
|
182
|
+
for f in data.get("files_written", []):
|
|
183
|
+
_emit(f" > {f}")
|
|
184
|
+
|
|
185
|
+
# Execution profile
|
|
186
|
+
if self.profile:
|
|
187
|
+
has_bytes = any(p.bytes_in is not None for p in self.profile)
|
|
188
|
+
_emit(div)
|
|
189
|
+
if has_bytes:
|
|
190
|
+
prefix_w = 51
|
|
191
|
+
hdr = " {:>3s} {:>8s} {:>7s} {:>7s} {:>5s} {:>10s} {}"
|
|
192
|
+
_emit(
|
|
193
|
+
hdr.format(
|
|
194
|
+
"#",
|
|
195
|
+
"time",
|
|
196
|
+
"in",
|
|
197
|
+
"out",
|
|
198
|
+
"spawn",
|
|
199
|
+
"bytes",
|
|
200
|
+
"command",
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
prefix_w = 39
|
|
205
|
+
hdr = " {:>3s} {:>8s} {:>7s} {:>7s} {:>5s} {}"
|
|
206
|
+
_emit(
|
|
207
|
+
hdr.format(
|
|
208
|
+
"#",
|
|
209
|
+
"time",
|
|
210
|
+
"in",
|
|
211
|
+
"out",
|
|
212
|
+
"spawn",
|
|
213
|
+
"command",
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
_emit(div)
|
|
217
|
+
|
|
218
|
+
cmd_w = W - prefix_w
|
|
219
|
+
pad = " " * prefix_w
|
|
220
|
+
|
|
221
|
+
for i, p in enumerate(self.profile):
|
|
222
|
+
e = f"{p.elapsed:7.3f}s"
|
|
223
|
+
li = f"{p.lines_in:7d}"
|
|
224
|
+
lo = f"{p.lines_out:7d}"
|
|
225
|
+
sp = f"{p.spawns:5d}"
|
|
226
|
+
if has_bytes:
|
|
227
|
+
b = _fmt_bytes(p.bytes_in) if p.bytes_in is not None else "-"
|
|
228
|
+
prefix = f" {i:3d} {e} {li} {lo} {sp} {b:>10s} "
|
|
229
|
+
else:
|
|
230
|
+
prefix = f" {i:3d} {e} {li} {lo} {sp} "
|
|
231
|
+
_emit_wrapped(prefix, p.name, cmd_w, pad)
|
|
232
|
+
|
|
233
|
+
_emit(div)
|
|
234
|
+
e = f"{self.elapsed:7.3f}s"
|
|
235
|
+
if has_bytes:
|
|
236
|
+
_emit(f" {'':3s} {e} {'':7s} {'':7s} {'':5s} {'':10s} total")
|
|
237
|
+
else:
|
|
238
|
+
_emit(f" {'':3s} {e} {'':7s} {'':7s} {'':5s} total")
|
|
239
|
+
|
|
240
|
+
# Checks
|
|
241
|
+
if self.checks:
|
|
242
|
+
_emit("-" * W)
|
|
243
|
+
for c in self.checks:
|
|
244
|
+
mark = "ok" if c.ok else "!!"
|
|
245
|
+
_emit(f" [{mark}] {c.name}: {c.found}")
|
|
246
|
+
|
|
247
|
+
_emit("=" * W)
|
|
248
|
+
|
|
249
|
+
def sbom(self) -> dict:
|
|
250
|
+
|
|
251
|
+
# Collect unique tools
|
|
252
|
+
tools: list[dict] = []
|
|
253
|
+
seen: set[str] = set()
|
|
254
|
+
files_read: list[str] = []
|
|
255
|
+
files_written: list[str] = []
|
|
256
|
+
|
|
257
|
+
for step in self._steps:
|
|
258
|
+
if isinstance(step, CmdStep) and step.binary not in seen:
|
|
259
|
+
seen.add(step.binary)
|
|
260
|
+
path = shutil.which(step.binary)
|
|
261
|
+
version = _detect_version(step.binary) if path else None
|
|
262
|
+
entry: dict = {"name": step.binary}
|
|
263
|
+
if path:
|
|
264
|
+
entry["path"] = path
|
|
265
|
+
if version:
|
|
266
|
+
entry["version"] = version
|
|
267
|
+
tools.append(entry)
|
|
268
|
+
|
|
269
|
+
if isinstance(step, FromFileStep):
|
|
270
|
+
files_read.append(step.path)
|
|
271
|
+
|
|
272
|
+
if isinstance(step, RedirectStep):
|
|
273
|
+
if step.stdout:
|
|
274
|
+
files_written.append(step.stdout)
|
|
275
|
+
if step.stderr:
|
|
276
|
+
files_written.append(step.stderr)
|
|
277
|
+
|
|
278
|
+
# Build execution call tree
|
|
279
|
+
call_tree: list[dict] = []
|
|
280
|
+
for i, step in enumerate(self._steps):
|
|
281
|
+
node: dict = {"step": i, "type": type(step).__name__}
|
|
282
|
+
if isinstance(step, CmdStep):
|
|
283
|
+
node["cmd"] = shlex.join([step.binary, *step.args])
|
|
284
|
+
if step.capture:
|
|
285
|
+
node["capture"] = step.capture
|
|
286
|
+
if step.merge_stderr:
|
|
287
|
+
node["merge_stderr"] = True
|
|
288
|
+
elif isinstance(step, FilterStep):
|
|
289
|
+
node["fn"] = step.fn.__name__ if hasattr(step.fn, "__name__") else "<lambda>"
|
|
290
|
+
elif isinstance(step, EachStep):
|
|
291
|
+
node["fn"] = step.fn.__name__ if hasattr(step.fn, "__name__") else "<lambda>"
|
|
292
|
+
node["workers"] = step.workers
|
|
293
|
+
elif isinstance(step, PeekStep):
|
|
294
|
+
if step.label:
|
|
295
|
+
node["label"] = step.label
|
|
296
|
+
elif isinstance(step, CaptureStep):
|
|
297
|
+
node["name"] = step.name
|
|
298
|
+
elif isinstance(step, RedirectStep):
|
|
299
|
+
if step.stdout:
|
|
300
|
+
node["stdout"] = step.stdout
|
|
301
|
+
if step.stderr:
|
|
302
|
+
node["stderr"] = step.stderr
|
|
303
|
+
if step.append:
|
|
304
|
+
node["append"] = True
|
|
305
|
+
elif isinstance(step, FeedStep):
|
|
306
|
+
node["bytes"] = len(step.data)
|
|
307
|
+
elif isinstance(step, FromFileStep):
|
|
308
|
+
node["path"] = step.path
|
|
309
|
+
if step.block_size:
|
|
310
|
+
node["block_size"] = step.block_size
|
|
311
|
+
elif isinstance(step, MeterStep):
|
|
312
|
+
if step.label:
|
|
313
|
+
node["label"] = step.label
|
|
314
|
+
call_tree.append(node)
|
|
315
|
+
|
|
316
|
+
import platform
|
|
317
|
+
|
|
318
|
+
sbom: dict = {
|
|
319
|
+
"generator": {
|
|
320
|
+
"name": "clichain",
|
|
321
|
+
"version": _version,
|
|
322
|
+
"python": sys.version.split()[0],
|
|
323
|
+
},
|
|
324
|
+
"environment": {
|
|
325
|
+
"os": platform.system(),
|
|
326
|
+
"os_version": platform.release(),
|
|
327
|
+
"arch": platform.machine(),
|
|
328
|
+
"hostname": platform.node(),
|
|
329
|
+
"cwd": os.getcwd(),
|
|
330
|
+
},
|
|
331
|
+
"timestamp": self._timestamp,
|
|
332
|
+
"elapsed": round(self.elapsed, 4),
|
|
333
|
+
"exit_code": self.returncode,
|
|
334
|
+
"tools": tools,
|
|
335
|
+
"call_tree": call_tree,
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if files_read:
|
|
339
|
+
sbom["files_read"] = files_read
|
|
340
|
+
if files_written:
|
|
341
|
+
sbom["files_written"] = files_written
|
|
342
|
+
|
|
343
|
+
return sbom
|
|
344
|
+
|
|
345
|
+
def sbom_json(self, indent: int = 2) -> str:
|
|
346
|
+
return json.dumps(self.sbom(), indent=indent)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# -- Output destination -------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
_SIGNALS: dict[int, tuple[str, str]] = {
|
|
352
|
+
1: ("SIGHUP", "terminal hangup"),
|
|
353
|
+
2: ("SIGINT", "interrupted (ctrl+c)"),
|
|
354
|
+
3: ("SIGQUIT", "quit"),
|
|
355
|
+
4: ("SIGILL", "illegal instruction"),
|
|
356
|
+
6: ("SIGABRT", "aborted"),
|
|
357
|
+
8: ("SIGFPE", "floating point error"),
|
|
358
|
+
9: ("SIGKILL", "killed"),
|
|
359
|
+
11: ("SIGSEGV", "segmentation fault"),
|
|
360
|
+
13: ("SIGPIPE", "broken pipe — downstream closed before upstream finished"),
|
|
361
|
+
14: ("SIGALRM", "timeout (alarm)"),
|
|
362
|
+
15: ("SIGTERM", "terminated"),
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
_EXIT_CODES: dict[int, str] = {
|
|
366
|
+
1: "general error",
|
|
367
|
+
2: "misuse of shell builtin / invalid arguments",
|
|
368
|
+
126: "command found but not executable (permission denied?)",
|
|
369
|
+
127: "command not found",
|
|
370
|
+
128: "invalid exit argument",
|
|
371
|
+
255: "exit status out of range",
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
_ERROR_DETAIL: dict[str, str] = {
|
|
376
|
+
"S13": (
|
|
377
|
+
"This usually means a downstream command (like head or grep -m)\n"
|
|
378
|
+
" closed its input before the upstream command finished writing.\n"
|
|
379
|
+
" This is often normal behavior, not a real error."
|
|
380
|
+
),
|
|
381
|
+
"S9": (
|
|
382
|
+
"The process was forcefully killed. This can happen from an OOM\n"
|
|
383
|
+
" killer, a timeout, or an explicit kill command."
|
|
384
|
+
),
|
|
385
|
+
"S2": (
|
|
386
|
+
"The process was interrupted, typically by ctrl+c or a CI runner\n cancelling the job."
|
|
387
|
+
),
|
|
388
|
+
"S15": (
|
|
389
|
+
"The process was asked to terminate gracefully. This is the\n"
|
|
390
|
+
" standard shutdown signal from process managers and CI systems."
|
|
391
|
+
),
|
|
392
|
+
"S11": (
|
|
393
|
+
"The process crashed due to a memory access violation. This is\n"
|
|
394
|
+
" a bug in the tool itself, not in your pipeline."
|
|
395
|
+
),
|
|
396
|
+
"X127": (
|
|
397
|
+
"The binary was not found in any directory listed in $PATH.\n"
|
|
398
|
+
" Check that the tool is installed and its location is in your PATH."
|
|
399
|
+
),
|
|
400
|
+
"X126": (
|
|
401
|
+
"The binary was found but could not be executed. Check file\n"
|
|
402
|
+
" permissions (chmod +x) and that it's a valid executable."
|
|
403
|
+
),
|
|
404
|
+
"X1": (
|
|
405
|
+
"The command returned a general error. Check stderr output above\n"
|
|
406
|
+
" for details from the command itself."
|
|
407
|
+
),
|
|
408
|
+
"X2": (
|
|
409
|
+
"The command received invalid arguments. Check the flags and\n"
|
|
410
|
+
" parameters passed to the command."
|
|
411
|
+
),
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _error_code(returncode: int) -> str:
|
|
416
|
+
if returncode < 0:
|
|
417
|
+
return f"S{abs(returncode)}"
|
|
418
|
+
if returncode > 128:
|
|
419
|
+
return f"S{returncode - 128}"
|
|
420
|
+
return f"X{returncode}"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _step_trace_line(i: int, step: Step, failed: bool = False) -> str:
|
|
424
|
+
step_type = type(step).__name__.replace("Step", "")
|
|
425
|
+
name = _step_name(step)
|
|
426
|
+
|
|
427
|
+
# Try to get source location for callables
|
|
428
|
+
loc = ""
|
|
429
|
+
fn = None
|
|
430
|
+
if isinstance(step, (FilterStep, EachStep)) or (isinstance(step, PeekStep) and step.fn):
|
|
431
|
+
fn = step.fn
|
|
432
|
+
|
|
433
|
+
if fn is not None:
|
|
434
|
+
try:
|
|
435
|
+
src_file = inspect.getfile(fn)
|
|
436
|
+
_, src_lineno = inspect.getsourcelines(fn)
|
|
437
|
+
short_file = os.path.basename(src_file)
|
|
438
|
+
loc = f" at {short_file}:{src_lineno}"
|
|
439
|
+
except (TypeError, OSError):
|
|
440
|
+
pass
|
|
441
|
+
|
|
442
|
+
marker = " <--" if failed else ""
|
|
443
|
+
return f" [{i}] {step_type:<10s} {name}{loc}{marker}"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _get_caller_location() -> str:
|
|
447
|
+
"""Get the source location of the .run() call.
|
|
448
|
+
Walk up the stack until we leave clichain code."""
|
|
449
|
+
try:
|
|
450
|
+
this_file = os.path.abspath(__file__)
|
|
451
|
+
this_dir = os.path.dirname(this_file)
|
|
452
|
+
for frame_info in inspect.stack():
|
|
453
|
+
if not os.path.abspath(frame_info.filename).startswith(this_dir):
|
|
454
|
+
return f"{frame_info.filename}:{frame_info.lineno}"
|
|
455
|
+
except (OSError, IndexError):
|
|
456
|
+
pass
|
|
457
|
+
return ""
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _explain_exit(code: int) -> str:
|
|
461
|
+
"""Translate an exit code into a human-readable explanation."""
|
|
462
|
+
if code == 0:
|
|
463
|
+
return "exit 0"
|
|
464
|
+
|
|
465
|
+
# Negative codes are signals (Python subprocess convention)
|
|
466
|
+
if code < 0:
|
|
467
|
+
sig = abs(code)
|
|
468
|
+
if sig in _SIGNALS:
|
|
469
|
+
name, desc = _SIGNALS[sig]
|
|
470
|
+
return f"signal {sig} ({name}: {desc})"
|
|
471
|
+
return f"signal {sig}"
|
|
472
|
+
|
|
473
|
+
# 128+N convention (shell reports signals this way)
|
|
474
|
+
if code > 128 and (code - 128) in _SIGNALS:
|
|
475
|
+
sig = code - 128
|
|
476
|
+
name, desc = _SIGNALS[sig]
|
|
477
|
+
return f"exit {code} — signal {sig} ({name}: {desc})"
|
|
478
|
+
|
|
479
|
+
# Known exit codes
|
|
480
|
+
if code in _EXIT_CODES:
|
|
481
|
+
return f"exit {code} — {_EXIT_CODES[code]}"
|
|
482
|
+
|
|
483
|
+
return f"exit {code}"
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
_output: IO[str] | Callable[[str], None] | None = sys.stderr
|
|
487
|
+
_version: str = "0.1.0"
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def set_output(dest: IO[str] | Callable[[str], None] | None) -> None:
|
|
491
|
+
global _output
|
|
492
|
+
_output = dest
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _emit(msg: str) -> None:
|
|
496
|
+
dest = _output
|
|
497
|
+
if dest is None:
|
|
498
|
+
return
|
|
499
|
+
write = getattr(dest, "write", None)
|
|
500
|
+
if write is not None:
|
|
501
|
+
write(msg + "\n")
|
|
502
|
+
getattr(dest, "flush", lambda: None)()
|
|
503
|
+
elif callable(dest):
|
|
504
|
+
dest(msg)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _emit_wrapped(prefix: str, text: str, width: int, pad: str) -> None:
|
|
508
|
+
"""Emit prefix + text, wrapping text at pipe boundaries to fit width."""
|
|
509
|
+
if len(text) <= width:
|
|
510
|
+
_emit(prefix + text)
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
# Split on pipe boundaries for readable wrapping
|
|
514
|
+
parts = text.split(" | ")
|
|
515
|
+
line = prefix
|
|
516
|
+
current = ""
|
|
517
|
+
|
|
518
|
+
for part in parts:
|
|
519
|
+
candidate = (current + " | " + part) if current else part
|
|
520
|
+
if current and len(candidate) > width:
|
|
521
|
+
_emit(line + current)
|
|
522
|
+
line = pad
|
|
523
|
+
current = "| " + part
|
|
524
|
+
else:
|
|
525
|
+
current = candidate
|
|
526
|
+
|
|
527
|
+
if current:
|
|
528
|
+
_emit(line + current)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# -- Steps -------------------------------------------------------------------
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@dataclass
|
|
535
|
+
class CmdStep:
|
|
536
|
+
binary: str
|
|
537
|
+
args: list[str]
|
|
538
|
+
capture: str | None = None
|
|
539
|
+
merge_stderr: bool = False
|
|
540
|
+
version: str | None = None
|
|
541
|
+
on_fail: OnFail = "error"
|
|
542
|
+
msg: str = ""
|
|
543
|
+
|
|
544
|
+
def resolve_args(self, ctx: dict[str, str]) -> list[str]:
|
|
545
|
+
if not ctx:
|
|
546
|
+
return [self.binary, *self.args]
|
|
547
|
+
return [self.binary, *(a.format_map(ctx) for a in self.args)]
|
|
548
|
+
|
|
549
|
+
def as_check(self) -> Callable[[], CheckResult]:
|
|
550
|
+
return binary_check(self.binary, version=self.version, on_fail=self.on_fail, msg=self.msg)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
@dataclass
|
|
554
|
+
class FilterStep:
|
|
555
|
+
fn: Callable[[str], bool]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@dataclass
|
|
559
|
+
class EachStep:
|
|
560
|
+
fn: Callable[[str], Cmd]
|
|
561
|
+
workers: int = 1
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@dataclass
|
|
565
|
+
class PeekStep:
|
|
566
|
+
label: str | None = None
|
|
567
|
+
fn: Callable[[str], None] | None = None
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
@dataclass
|
|
571
|
+
class CollectStep:
|
|
572
|
+
pass
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@dataclass
|
|
576
|
+
class CaptureStep:
|
|
577
|
+
name: str
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
@dataclass
|
|
581
|
+
class RedirectStep:
|
|
582
|
+
stdout: str | None = None
|
|
583
|
+
stderr: str | None = None
|
|
584
|
+
append: bool = False
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@dataclass
|
|
588
|
+
class FeedStep:
|
|
589
|
+
data: str
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@dataclass
|
|
593
|
+
class FromFileStep:
|
|
594
|
+
path: str
|
|
595
|
+
block_size: int = 0 # 0 = let kernel handle it
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
@dataclass
|
|
599
|
+
class MeterStep:
|
|
600
|
+
label: str | None = None
|
|
601
|
+
to: Callable[[MeterStats], None] | IO[str] | None = None
|
|
602
|
+
interval: float = 1.0
|
|
603
|
+
_stats: MeterStats = field(default_factory=MeterStats)
|
|
604
|
+
|
|
605
|
+
|
|
606
|
+
Step = (
|
|
607
|
+
CmdStep
|
|
608
|
+
| FilterStep
|
|
609
|
+
| EachStep
|
|
610
|
+
| PeekStep
|
|
611
|
+
| CollectStep
|
|
612
|
+
| CaptureStep
|
|
613
|
+
| RedirectStep
|
|
614
|
+
| FeedStep
|
|
615
|
+
| FromFileStep
|
|
616
|
+
| MeterStep
|
|
617
|
+
)
|
|
618
|
+
|
|
619
|
+
StreamableStep = CmdStep | FilterStep | PeekStep | FromFileStep | MeterStep
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _is_streamable(step: Step) -> bool:
|
|
623
|
+
return isinstance(step, (CmdStep, FilterStep, PeekStep, FromFileStep, MeterStep))
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
# -- Streaming helpers --------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _default_peek_fn(label: str | None) -> Callable[[str], None]:
|
|
630
|
+
def _peek(line: str) -> None:
|
|
631
|
+
if label:
|
|
632
|
+
_emit(f"[{label}] {line.rstrip()}")
|
|
633
|
+
else:
|
|
634
|
+
_emit(line.rstrip())
|
|
635
|
+
|
|
636
|
+
return _peek
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _thread_filter(src: IO[str], dest: IO[str], fn: Callable[[str], bool]) -> None:
|
|
640
|
+
try:
|
|
641
|
+
for line in src:
|
|
642
|
+
stripped = line.rstrip("\n")
|
|
643
|
+
if fn(stripped):
|
|
644
|
+
dest.write(line)
|
|
645
|
+
dest.flush()
|
|
646
|
+
finally:
|
|
647
|
+
dest.close()
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _thread_peek(src: IO[str], dest: IO[str], fn: Callable[[str], None]) -> None:
|
|
651
|
+
try:
|
|
652
|
+
for line in src:
|
|
653
|
+
fn(line)
|
|
654
|
+
dest.write(line)
|
|
655
|
+
dest.flush()
|
|
656
|
+
finally:
|
|
657
|
+
dest.close()
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _thread_block_read(
|
|
661
|
+
path: str,
|
|
662
|
+
dest: IO[bytes],
|
|
663
|
+
block_size: int,
|
|
664
|
+
) -> None:
|
|
665
|
+
"""Read a file in fixed-size chunks and write to a binary pipe."""
|
|
666
|
+
try:
|
|
667
|
+
with open(path, "rb") as src:
|
|
668
|
+
while True:
|
|
669
|
+
chunk = src.read(block_size)
|
|
670
|
+
if not chunk:
|
|
671
|
+
break
|
|
672
|
+
dest.write(chunk)
|
|
673
|
+
dest.flush()
|
|
674
|
+
finally:
|
|
675
|
+
dest.close()
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _emit_meter_stats(stats: MeterStats, to: Callable[[MeterStats], None] | IO[str] | None) -> None:
|
|
679
|
+
msg = (
|
|
680
|
+
f"[{stats.label or 'meter'}] "
|
|
681
|
+
f"{_fmt_bytes(stats.bytes)} "
|
|
682
|
+
f"{_fmt_bytes(int(stats.bytes_per_sec))}/s "
|
|
683
|
+
f"{stats.lines} lines"
|
|
684
|
+
)
|
|
685
|
+
if to is None:
|
|
686
|
+
_emit(msg)
|
|
687
|
+
elif hasattr(to, "write"):
|
|
688
|
+
dest: IO[str] = to # type: ignore[assignment]
|
|
689
|
+
dest.write(msg + "\n")
|
|
690
|
+
dest.flush()
|
|
691
|
+
elif callable(to):
|
|
692
|
+
to(stats)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _fmt_bytes(n: int) -> str:
|
|
696
|
+
if n < 1024:
|
|
697
|
+
return f"{n} B"
|
|
698
|
+
if n < 1024 * 1024:
|
|
699
|
+
return f"{n / 1024:.1f} KB"
|
|
700
|
+
if n < 1024 * 1024 * 1024:
|
|
701
|
+
return f"{n / (1024 * 1024):.1f} MB"
|
|
702
|
+
return f"{n / (1024 * 1024 * 1024):.1f} GB"
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _thread_meter(
|
|
706
|
+
src: IO[str],
|
|
707
|
+
dest: IO[str],
|
|
708
|
+
step: MeterStep,
|
|
709
|
+
) -> None:
|
|
710
|
+
stats = step._stats
|
|
711
|
+
stats.label = step.label or ""
|
|
712
|
+
start = time.monotonic()
|
|
713
|
+
last_emit = start
|
|
714
|
+
|
|
715
|
+
try:
|
|
716
|
+
for line in src:
|
|
717
|
+
stats.bytes += len(line.encode())
|
|
718
|
+
stats.lines += 1
|
|
719
|
+
dest.write(line)
|
|
720
|
+
dest.flush()
|
|
721
|
+
|
|
722
|
+
now = time.monotonic()
|
|
723
|
+
stats.elapsed = now - start
|
|
724
|
+
if step.interval == 0 or (now - last_emit) >= step.interval:
|
|
725
|
+
stats.bytes_per_sec = stats.bytes / stats.elapsed if stats.elapsed > 0 else 0
|
|
726
|
+
stats.lines_per_sec = stats.lines / stats.elapsed if stats.elapsed > 0 else 0
|
|
727
|
+
_emit_meter_stats(stats, step.to)
|
|
728
|
+
last_emit = now
|
|
729
|
+
|
|
730
|
+
# Final stats
|
|
731
|
+
stats.elapsed = time.monotonic() - start
|
|
732
|
+
stats.bytes_per_sec = stats.bytes / stats.elapsed if stats.elapsed > 0 else 0
|
|
733
|
+
stats.lines_per_sec = stats.lines / stats.elapsed if stats.elapsed > 0 else 0
|
|
734
|
+
finally:
|
|
735
|
+
dest.close()
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
# -- Process group ------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
_GRACEFUL_TIMEOUT = 5
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
class ProcessGroup:
|
|
744
|
+
"""Tracks child processes and threads for coordinated cleanup.
|
|
745
|
+
|
|
746
|
+
On Unix, uses os.setpgrp so children share a process group.
|
|
747
|
+
On Windows, tracks processes manually.
|
|
748
|
+
Installs signal handlers for SIGINT/SIGTERM during execution.
|
|
749
|
+
"""
|
|
750
|
+
|
|
751
|
+
def __init__(self) -> None:
|
|
752
|
+
self._procs: list[subprocess.Popen[str]] = []
|
|
753
|
+
self._threads: list[threading.Thread] = []
|
|
754
|
+
self._file_handles: list[IO[str]] = []
|
|
755
|
+
self._lock = threading.RLock()
|
|
756
|
+
self._interrupted = False
|
|
757
|
+
self._prev_sigint: object = None
|
|
758
|
+
self._prev_sigterm: object = None
|
|
759
|
+
|
|
760
|
+
def __enter__(self) -> ProcessGroup:
|
|
761
|
+
if threading.current_thread() is threading.main_thread():
|
|
762
|
+
self._prev_sigint = signal.getsignal(signal.SIGINT)
|
|
763
|
+
self._prev_sigterm = signal.getsignal(signal.SIGTERM)
|
|
764
|
+
signal.signal(signal.SIGINT, self._handle_signal)
|
|
765
|
+
signal.signal(signal.SIGTERM, self._handle_signal)
|
|
766
|
+
return self
|
|
767
|
+
|
|
768
|
+
def __exit__(self, *_: object) -> None:
|
|
769
|
+
if threading.current_thread() is threading.main_thread():
|
|
770
|
+
if self._prev_sigint is not None:
|
|
771
|
+
signal.signal(
|
|
772
|
+
signal.SIGINT,
|
|
773
|
+
self._prev_sigint, # type: ignore[arg-type]
|
|
774
|
+
)
|
|
775
|
+
if self._prev_sigterm is not None:
|
|
776
|
+
signal.signal(
|
|
777
|
+
signal.SIGTERM,
|
|
778
|
+
self._prev_sigterm, # type: ignore[arg-type]
|
|
779
|
+
)
|
|
780
|
+
# Clean up anything still running
|
|
781
|
+
if self._procs or self._file_handles:
|
|
782
|
+
self._cleanup()
|
|
783
|
+
|
|
784
|
+
@property
|
|
785
|
+
def interrupted(self) -> bool:
|
|
786
|
+
return self._interrupted
|
|
787
|
+
|
|
788
|
+
def spawn(
|
|
789
|
+
self,
|
|
790
|
+
args: list[str],
|
|
791
|
+
**kwargs: object,
|
|
792
|
+
) -> subprocess.Popen[str]:
|
|
793
|
+
proc = subprocess.Popen(args, **kwargs) # type: ignore[call-overload]
|
|
794
|
+
with self._lock:
|
|
795
|
+
self._procs.append(proc)
|
|
796
|
+
return proc
|
|
797
|
+
|
|
798
|
+
def add_thread(self, t: threading.Thread) -> None:
|
|
799
|
+
with self._lock:
|
|
800
|
+
self._threads.append(t)
|
|
801
|
+
|
|
802
|
+
def add_file(self, fh: IO[str]) -> None:
|
|
803
|
+
with self._lock:
|
|
804
|
+
self._file_handles.append(fh)
|
|
805
|
+
|
|
806
|
+
def _handle_signal(self, signum: int, _frame: object) -> None:
|
|
807
|
+
self._interrupted = True
|
|
808
|
+
self._cleanup()
|
|
809
|
+
# Re-raise so the caller sees it
|
|
810
|
+
sys.exit(128 + signum)
|
|
811
|
+
|
|
812
|
+
def _cleanup(self) -> None:
|
|
813
|
+
# Terminate all child processes gracefully
|
|
814
|
+
with self._lock:
|
|
815
|
+
procs = list(self._procs)
|
|
816
|
+
threads = list(self._threads)
|
|
817
|
+
handles = list(self._file_handles)
|
|
818
|
+
|
|
819
|
+
for proc in procs:
|
|
820
|
+
if proc.poll() is None:
|
|
821
|
+
proc.terminate()
|
|
822
|
+
|
|
823
|
+
# Wait for graceful exit
|
|
824
|
+
deadline = time.monotonic() + _GRACEFUL_TIMEOUT
|
|
825
|
+
for proc in procs:
|
|
826
|
+
remaining = max(0, deadline - time.monotonic())
|
|
827
|
+
try:
|
|
828
|
+
proc.wait(timeout=remaining)
|
|
829
|
+
except subprocess.TimeoutExpired:
|
|
830
|
+
proc.kill()
|
|
831
|
+
proc.wait()
|
|
832
|
+
|
|
833
|
+
# Join threads
|
|
834
|
+
for t in threads:
|
|
835
|
+
t.join(timeout=1)
|
|
836
|
+
|
|
837
|
+
# Close file handles
|
|
838
|
+
import contextlib
|
|
839
|
+
|
|
840
|
+
for fh in handles:
|
|
841
|
+
with contextlib.suppress(OSError):
|
|
842
|
+
fh.close()
|
|
843
|
+
|
|
844
|
+
with self._lock:
|
|
845
|
+
self._procs.clear()
|
|
846
|
+
self._threads.clear()
|
|
847
|
+
self._file_handles.clear()
|
|
848
|
+
|
|
849
|
+
|
|
850
|
+
# -- Pipeline -----------------------------------------------------------------
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
class Pipeline:
|
|
854
|
+
def __init__(self, steps: list[Step]) -> None:
|
|
855
|
+
self._steps = steps
|
|
856
|
+
|
|
857
|
+
# -- Builder methods ------------------------------------------------------
|
|
858
|
+
|
|
859
|
+
def pipe(self, cmd: Cmd) -> Pipeline:
|
|
860
|
+
self._steps.append(
|
|
861
|
+
CmdStep(
|
|
862
|
+
cmd._binary,
|
|
863
|
+
list(cmd._args),
|
|
864
|
+
version=cmd._version,
|
|
865
|
+
on_fail=cmd._on_fail,
|
|
866
|
+
msg=cmd._msg,
|
|
867
|
+
)
|
|
868
|
+
)
|
|
869
|
+
return self
|
|
870
|
+
|
|
871
|
+
def filter(self, fn: Callable[[str], bool]) -> Pipeline:
|
|
872
|
+
self._steps.append(FilterStep(fn))
|
|
873
|
+
return self
|
|
874
|
+
|
|
875
|
+
def each(self, fn: Callable[[str], Cmd], workers: int = 1) -> Pipeline:
|
|
876
|
+
self._steps.append(EachStep(fn, workers=workers))
|
|
877
|
+
return self
|
|
878
|
+
|
|
879
|
+
def peek(self, label: str | None = None, fn: Callable[[str], None] | None = None) -> Pipeline:
|
|
880
|
+
self._steps.append(PeekStep(label=label, fn=fn))
|
|
881
|
+
return self
|
|
882
|
+
|
|
883
|
+
def meter(
|
|
884
|
+
self,
|
|
885
|
+
label: str | None = None,
|
|
886
|
+
to: Callable[[MeterStats], None] | IO[str] | None = None,
|
|
887
|
+
interval: float = 1.0,
|
|
888
|
+
) -> Pipeline:
|
|
889
|
+
self._steps.append(MeterStep(label=label, to=to, interval=interval))
|
|
890
|
+
return self
|
|
891
|
+
|
|
892
|
+
def collect(self) -> Pipeline:
|
|
893
|
+
self._steps.append(CollectStep())
|
|
894
|
+
return self
|
|
895
|
+
|
|
896
|
+
def capture(self, name: str) -> Pipeline:
|
|
897
|
+
self._steps.append(CaptureStep(name))
|
|
898
|
+
return self
|
|
899
|
+
|
|
900
|
+
def merge_stderr(self) -> Pipeline:
|
|
901
|
+
for step in reversed(self._steps):
|
|
902
|
+
if isinstance(step, CmdStep):
|
|
903
|
+
step.merge_stderr = True
|
|
904
|
+
break
|
|
905
|
+
return self
|
|
906
|
+
|
|
907
|
+
def redirect(
|
|
908
|
+
self,
|
|
909
|
+
stdout: str | None = None,
|
|
910
|
+
stderr: str | None = None,
|
|
911
|
+
append: bool = False,
|
|
912
|
+
) -> Pipeline:
|
|
913
|
+
self._steps.append(RedirectStep(stdout=stdout, stderr=stderr, append=append))
|
|
914
|
+
return self
|
|
915
|
+
|
|
916
|
+
def feed(self, data: str) -> Pipeline:
|
|
917
|
+
self._steps.insert(0, FeedStep(data))
|
|
918
|
+
return self
|
|
919
|
+
|
|
920
|
+
def from_file(self, path: str, block_size: int = 0) -> Pipeline:
|
|
921
|
+
self._steps.insert(0, FromFileStep(path, block_size=block_size))
|
|
922
|
+
return self
|
|
923
|
+
|
|
924
|
+
# -- Validation -----------------------------------------------------------
|
|
925
|
+
|
|
926
|
+
def check(self) -> list[CheckResult]:
|
|
927
|
+
checks = []
|
|
928
|
+
seen: set[str] = set()
|
|
929
|
+
for step in self._steps:
|
|
930
|
+
if not isinstance(step, CmdStep):
|
|
931
|
+
continue
|
|
932
|
+
if step.binary in seen:
|
|
933
|
+
continue
|
|
934
|
+
seen.add(step.binary)
|
|
935
|
+
checks.append(step.as_check())
|
|
936
|
+
results = run_checks(checks)
|
|
937
|
+
for r in results:
|
|
938
|
+
if r.should_report:
|
|
939
|
+
_emit(r.format())
|
|
940
|
+
return results
|
|
941
|
+
|
|
942
|
+
# -- Describe -------------------------------------------------------------
|
|
943
|
+
|
|
944
|
+
def describe(self) -> None:
|
|
945
|
+
"""Show what this pipeline does without executing."""
|
|
946
|
+
W = 72
|
|
947
|
+
sep = "=" * W
|
|
948
|
+
div = "-" * W
|
|
949
|
+
|
|
950
|
+
_emit(sep)
|
|
951
|
+
_emit(f" pipeline: {len(self._steps)} steps")
|
|
952
|
+
_emit(div)
|
|
953
|
+
|
|
954
|
+
for i, step in enumerate(self._steps):
|
|
955
|
+
_emit(f" [{i}] {type(step).__name__.replace('Step', ''):<10s} {_step_name(step)}")
|
|
956
|
+
|
|
957
|
+
# Tools
|
|
958
|
+
seen: set[str] = set()
|
|
959
|
+
tools: list[CmdStep] = []
|
|
960
|
+
for step in self._steps:
|
|
961
|
+
if isinstance(step, CmdStep) and step.binary not in seen:
|
|
962
|
+
seen.add(step.binary)
|
|
963
|
+
tools.append(step)
|
|
964
|
+
|
|
965
|
+
if tools:
|
|
966
|
+
_emit(div)
|
|
967
|
+
_emit(" requires:")
|
|
968
|
+
checks = run_checks([t.as_check() for t in tools])
|
|
969
|
+
for t, c in zip(tools, checks, strict=True):
|
|
970
|
+
ver = f" {t.version}" if t.version else ""
|
|
971
|
+
status = "ok" if c.ok else "!!"
|
|
972
|
+
_emit(f" {t.binary}{ver:<12s} {c.found:<30s} {status}")
|
|
973
|
+
|
|
974
|
+
# Files
|
|
975
|
+
files_read = [s.path for s in self._steps if isinstance(s, FromFileStep)]
|
|
976
|
+
files_written: list[str] = []
|
|
977
|
+
for s in self._steps:
|
|
978
|
+
if isinstance(s, RedirectStep):
|
|
979
|
+
if s.stdout:
|
|
980
|
+
files_written.append(s.stdout)
|
|
981
|
+
if s.stderr:
|
|
982
|
+
files_written.append(s.stderr)
|
|
983
|
+
|
|
984
|
+
if files_read or files_written:
|
|
985
|
+
_emit(div)
|
|
986
|
+
for f in files_read:
|
|
987
|
+
_emit(f" < {f}")
|
|
988
|
+
for f in files_written:
|
|
989
|
+
_emit(f" > {f}")
|
|
990
|
+
|
|
991
|
+
_emit(sep)
|
|
992
|
+
|
|
993
|
+
def _describe_dict(self) -> dict:
|
|
994
|
+
"""Build a description dict from the step list without executing."""
|
|
995
|
+
import platform
|
|
996
|
+
|
|
997
|
+
seen: set[str] = set()
|
|
998
|
+
tools: list[dict] = []
|
|
999
|
+
for step in self._steps:
|
|
1000
|
+
if isinstance(step, CmdStep) and step.binary not in seen:
|
|
1001
|
+
seen.add(step.binary)
|
|
1002
|
+
path = shutil.which(step.binary)
|
|
1003
|
+
version = _detect_version(step.binary) if path else None
|
|
1004
|
+
entry: dict = {"name": step.binary}
|
|
1005
|
+
if step.version:
|
|
1006
|
+
entry["requires"] = step.version
|
|
1007
|
+
if path:
|
|
1008
|
+
entry["path"] = path
|
|
1009
|
+
if version:
|
|
1010
|
+
entry["version"] = version
|
|
1011
|
+
entry["found"] = path is not None
|
|
1012
|
+
tools.append(entry)
|
|
1013
|
+
|
|
1014
|
+
files_read = [s.path for s in self._steps if isinstance(s, FromFileStep)]
|
|
1015
|
+
files_written: list[str] = []
|
|
1016
|
+
for s in self._steps:
|
|
1017
|
+
if isinstance(s, RedirectStep):
|
|
1018
|
+
if s.stdout:
|
|
1019
|
+
files_written.append(s.stdout)
|
|
1020
|
+
if s.stderr:
|
|
1021
|
+
files_written.append(s.stderr)
|
|
1022
|
+
|
|
1023
|
+
steps = []
|
|
1024
|
+
for i, step in enumerate(self._steps):
|
|
1025
|
+
node: dict = {
|
|
1026
|
+
"step": i,
|
|
1027
|
+
"type": type(step).__name__,
|
|
1028
|
+
"name": _step_name(step),
|
|
1029
|
+
}
|
|
1030
|
+
steps.append(node)
|
|
1031
|
+
|
|
1032
|
+
result: dict = {
|
|
1033
|
+
"generator": {
|
|
1034
|
+
"name": "clichain",
|
|
1035
|
+
"version": _version,
|
|
1036
|
+
"python": sys.version.split()[0],
|
|
1037
|
+
},
|
|
1038
|
+
"environment": {
|
|
1039
|
+
"os": platform.system(),
|
|
1040
|
+
"arch": platform.machine(),
|
|
1041
|
+
},
|
|
1042
|
+
"steps": steps,
|
|
1043
|
+
"tools": tools,
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
if files_read:
|
|
1047
|
+
result["files_read"] = files_read
|
|
1048
|
+
if files_written:
|
|
1049
|
+
result["files_written"] = files_written
|
|
1050
|
+
|
|
1051
|
+
return result
|
|
1052
|
+
|
|
1053
|
+
# -- Execution ------------------------------------------------------------
|
|
1054
|
+
|
|
1055
|
+
def run(
|
|
1056
|
+
self,
|
|
1057
|
+
validate: bool = True,
|
|
1058
|
+
pre: list[Callable[[], CheckResult]] | None = None,
|
|
1059
|
+
) -> Result:
|
|
1060
|
+
describe_mode = os.environ.get("CLICHAIN_DESCRIBE")
|
|
1061
|
+
if describe_mode == "json":
|
|
1062
|
+
# Reuse sbom structure but pre-run (no timing/exit code)
|
|
1063
|
+
_emit(json.dumps(self._describe_dict(), indent=2))
|
|
1064
|
+
sys.exit(0)
|
|
1065
|
+
elif describe_mode:
|
|
1066
|
+
self.describe()
|
|
1067
|
+
sys.exit(0)
|
|
1068
|
+
|
|
1069
|
+
all_checks: list[CheckResult] = []
|
|
1070
|
+
|
|
1071
|
+
if validate or pre:
|
|
1072
|
+
if validate:
|
|
1073
|
+
all_checks.extend(self.check())
|
|
1074
|
+
|
|
1075
|
+
if pre:
|
|
1076
|
+
extra = run_checks(pre)
|
|
1077
|
+
for r in extra:
|
|
1078
|
+
if r.should_report:
|
|
1079
|
+
_emit(r.format())
|
|
1080
|
+
all_checks.extend(extra)
|
|
1081
|
+
|
|
1082
|
+
if any(c.should_stop for c in all_checks):
|
|
1083
|
+
errors = [c for c in all_checks if c.should_stop]
|
|
1084
|
+
return Result(
|
|
1085
|
+
stdout="",
|
|
1086
|
+
stderr="\n".join(c.format() for c in errors),
|
|
1087
|
+
returncode=1,
|
|
1088
|
+
checks=all_checks,
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
# Execute pipeline with profiling
|
|
1092
|
+
source_location = _get_caller_location()
|
|
1093
|
+
groups = self._plan()
|
|
1094
|
+
ctx: dict[str, str] = {}
|
|
1095
|
+
output = ""
|
|
1096
|
+
all_stderr: list[str] = []
|
|
1097
|
+
all_profiles: list[StepProfile] = []
|
|
1098
|
+
returncode = 0
|
|
1099
|
+
failed_step: int | None = None
|
|
1100
|
+
timestamp = datetime.now(timezone.utc).isoformat()
|
|
1101
|
+
pipeline_start = time.monotonic()
|
|
1102
|
+
|
|
1103
|
+
# Map group steps back to their index in self._steps
|
|
1104
|
+
step_offset = 0
|
|
1105
|
+
|
|
1106
|
+
pg = ProcessGroup()
|
|
1107
|
+
with pg:
|
|
1108
|
+
for group in groups:
|
|
1109
|
+
lines_in = _count_lines(output)
|
|
1110
|
+
t0 = time.monotonic()
|
|
1111
|
+
|
|
1112
|
+
output, rc, errs, spawns = self._exec_group(
|
|
1113
|
+
group,
|
|
1114
|
+
output,
|
|
1115
|
+
ctx,
|
|
1116
|
+
pg,
|
|
1117
|
+
)
|
|
1118
|
+
|
|
1119
|
+
elapsed = time.monotonic() - t0
|
|
1120
|
+
lines_out = _count_lines(output)
|
|
1121
|
+
|
|
1122
|
+
# Extract byte counts from any meter in this group
|
|
1123
|
+
bytes_in: int | None = None
|
|
1124
|
+
bytes_out: int | None = None
|
|
1125
|
+
for s in group:
|
|
1126
|
+
if isinstance(s, MeterStep):
|
|
1127
|
+
bytes_in = s._stats.bytes
|
|
1128
|
+
bytes_out = s._stats.bytes
|
|
1129
|
+
|
|
1130
|
+
all_profiles.append(
|
|
1131
|
+
StepProfile(
|
|
1132
|
+
name=_group_name(group),
|
|
1133
|
+
elapsed=elapsed,
|
|
1134
|
+
lines_in=lines_in,
|
|
1135
|
+
lines_out=lines_out,
|
|
1136
|
+
spawns=spawns,
|
|
1137
|
+
bytes_in=bytes_in,
|
|
1138
|
+
bytes_out=bytes_out,
|
|
1139
|
+
)
|
|
1140
|
+
)
|
|
1141
|
+
|
|
1142
|
+
all_stderr.extend(errs)
|
|
1143
|
+
if rc != 0:
|
|
1144
|
+
returncode = rc
|
|
1145
|
+
failed_step = step_offset + len(group) - 1
|
|
1146
|
+
for j, s in enumerate(group):
|
|
1147
|
+
if isinstance(s, CmdStep):
|
|
1148
|
+
cmd_name = shlex.join([s.binary, *s.args])
|
|
1149
|
+
if any(cmd_name in e for e in errs):
|
|
1150
|
+
failed_step = step_offset + j
|
|
1151
|
+
break
|
|
1152
|
+
break
|
|
1153
|
+
|
|
1154
|
+
step_offset += len(group)
|
|
1155
|
+
|
|
1156
|
+
if pg.interrupted:
|
|
1157
|
+
returncode = 130 # standard bash convention for SIGINT
|
|
1158
|
+
|
|
1159
|
+
pipeline_elapsed = time.monotonic() - pipeline_start
|
|
1160
|
+
|
|
1161
|
+
return Result(
|
|
1162
|
+
stdout=output,
|
|
1163
|
+
stderr="\n".join(all_stderr),
|
|
1164
|
+
returncode=returncode,
|
|
1165
|
+
checks=all_checks,
|
|
1166
|
+
profile=all_profiles,
|
|
1167
|
+
elapsed=pipeline_elapsed,
|
|
1168
|
+
_steps=list(self._steps),
|
|
1169
|
+
_timestamp=timestamp,
|
|
1170
|
+
_failed_step=failed_step,
|
|
1171
|
+
_source_location=source_location,
|
|
1172
|
+
)
|
|
1173
|
+
|
|
1174
|
+
# -- Planning: group streamable steps together ----------------------------
|
|
1175
|
+
|
|
1176
|
+
def _plan(self) -> list[list[Step]]:
|
|
1177
|
+
groups: list[list[Step]] = []
|
|
1178
|
+
current: list[Step] = []
|
|
1179
|
+
|
|
1180
|
+
for step in self._steps:
|
|
1181
|
+
if _is_streamable(step):
|
|
1182
|
+
current.append(step)
|
|
1183
|
+
continue
|
|
1184
|
+
if current:
|
|
1185
|
+
groups.append(current)
|
|
1186
|
+
current = []
|
|
1187
|
+
groups.append([step])
|
|
1188
|
+
|
|
1189
|
+
if current:
|
|
1190
|
+
groups.append(current)
|
|
1191
|
+
|
|
1192
|
+
return groups
|
|
1193
|
+
|
|
1194
|
+
def _exec_group(
|
|
1195
|
+
self,
|
|
1196
|
+
group: list[Step],
|
|
1197
|
+
input_data: str,
|
|
1198
|
+
ctx: dict[str, str],
|
|
1199
|
+
pg: ProcessGroup,
|
|
1200
|
+
) -> tuple[str, int, list[str], int]:
|
|
1201
|
+
"""Returns (output, returncode, stderr_parts, spawn_count)."""
|
|
1202
|
+
first = group[0]
|
|
1203
|
+
|
|
1204
|
+
if _is_streamable(first):
|
|
1205
|
+
return self._exec_stream_group(group, input_data, ctx, pg)
|
|
1206
|
+
|
|
1207
|
+
if isinstance(first, EachStep):
|
|
1208
|
+
out, rc, errs, spawns = self._exec_each(first, input_data)
|
|
1209
|
+
return out, rc, errs, spawns
|
|
1210
|
+
|
|
1211
|
+
if isinstance(first, CollectStep):
|
|
1212
|
+
return input_data, 0, [], 0
|
|
1213
|
+
|
|
1214
|
+
if isinstance(first, CaptureStep):
|
|
1215
|
+
ctx[first.name] = input_data.strip()
|
|
1216
|
+
return input_data, 0, [], 0
|
|
1217
|
+
|
|
1218
|
+
if isinstance(first, RedirectStep):
|
|
1219
|
+
return self._exec_redirect(first, input_data), 0, [], 0
|
|
1220
|
+
|
|
1221
|
+
if isinstance(first, FeedStep):
|
|
1222
|
+
return first.data, 0, [], 0
|
|
1223
|
+
|
|
1224
|
+
if isinstance(first, FromFileStep):
|
|
1225
|
+
with open(first.path) as f:
|
|
1226
|
+
return f.read(), 0, [], 0
|
|
1227
|
+
|
|
1228
|
+
return input_data, 0, [], 0
|
|
1229
|
+
|
|
1230
|
+
# -- Streaming execution: Popen + threads ---------------------------------
|
|
1231
|
+
|
|
1232
|
+
def _exec_stream_group(
|
|
1233
|
+
self,
|
|
1234
|
+
steps: list[Step],
|
|
1235
|
+
input_data: str,
|
|
1236
|
+
ctx: dict[str, str],
|
|
1237
|
+
pg: ProcessGroup,
|
|
1238
|
+
) -> tuple[str, int, list[str], int]:
|
|
1239
|
+
if len(steps) == 1 and isinstance(steps[0], CmdStep):
|
|
1240
|
+
out, rc, errs = self._exec_single_cmd(steps[0], input_data, ctx, pg)
|
|
1241
|
+
return out, rc, errs, 1
|
|
1242
|
+
|
|
1243
|
+
if len(steps) == 1 and isinstance(steps[0], FromFileStep):
|
|
1244
|
+
with open(steps[0].path) as f:
|
|
1245
|
+
return f.read(), 0, [], 0
|
|
1246
|
+
|
|
1247
|
+
procs: list[subprocess.Popen[str]] = []
|
|
1248
|
+
all_stderr: list[str] = []
|
|
1249
|
+
captures: list[tuple[int, str]] = []
|
|
1250
|
+
prev_stdout: IO[str] | None = None
|
|
1251
|
+
|
|
1252
|
+
for step in steps:
|
|
1253
|
+
if isinstance(step, FromFileStep):
|
|
1254
|
+
if step.block_size > 0:
|
|
1255
|
+
read_fd, write_fd = os.pipe()
|
|
1256
|
+
try:
|
|
1257
|
+
read_end = os.fdopen(read_fd, "rb")
|
|
1258
|
+
write_end = os.fdopen(write_fd, "wb")
|
|
1259
|
+
t = threading.Thread(
|
|
1260
|
+
target=_thread_block_read,
|
|
1261
|
+
args=(step.path, write_end, step.block_size),
|
|
1262
|
+
daemon=True,
|
|
1263
|
+
)
|
|
1264
|
+
t.start()
|
|
1265
|
+
except Exception:
|
|
1266
|
+
os.close(read_fd)
|
|
1267
|
+
os.close(write_fd)
|
|
1268
|
+
raise
|
|
1269
|
+
pg.add_thread(t)
|
|
1270
|
+
prev_stdout = read_end # type: ignore[assignment]
|
|
1271
|
+
else:
|
|
1272
|
+
fh = open(step.path) # noqa: SIM115
|
|
1273
|
+
pg.add_file(fh)
|
|
1274
|
+
prev_stdout = fh
|
|
1275
|
+
continue
|
|
1276
|
+
|
|
1277
|
+
if isinstance(step, CmdStep):
|
|
1278
|
+
args = step.resolve_args(ctx)
|
|
1279
|
+
stdin_src = prev_stdout if prev_stdout else subprocess.PIPE
|
|
1280
|
+
stderr_dst = subprocess.STDOUT if step.merge_stderr else subprocess.PIPE
|
|
1281
|
+
|
|
1282
|
+
try:
|
|
1283
|
+
proc = pg.spawn(
|
|
1284
|
+
args,
|
|
1285
|
+
stdin=stdin_src,
|
|
1286
|
+
stdout=subprocess.PIPE,
|
|
1287
|
+
stderr=stderr_dst,
|
|
1288
|
+
text=True,
|
|
1289
|
+
)
|
|
1290
|
+
except FileNotFoundError:
|
|
1291
|
+
return (
|
|
1292
|
+
"",
|
|
1293
|
+
127,
|
|
1294
|
+
[f"{step.binary}: command not found"],
|
|
1295
|
+
len(procs),
|
|
1296
|
+
)
|
|
1297
|
+
procs.append(proc)
|
|
1298
|
+
|
|
1299
|
+
if step.capture:
|
|
1300
|
+
captures.append((len(procs) - 1, step.capture))
|
|
1301
|
+
|
|
1302
|
+
if prev_stdout and prev_stdout is not proc.stdin:
|
|
1303
|
+
prev_stdout.close()
|
|
1304
|
+
|
|
1305
|
+
prev_stdout = proc.stdout
|
|
1306
|
+
|
|
1307
|
+
elif isinstance(step, (FilterStep, PeekStep, MeterStep)):
|
|
1308
|
+
read_fd, write_fd = os.pipe()
|
|
1309
|
+
try:
|
|
1310
|
+
read_end = os.fdopen(read_fd, "r")
|
|
1311
|
+
write_end = os.fdopen(write_fd, "w")
|
|
1312
|
+
|
|
1313
|
+
if isinstance(step, FilterStep):
|
|
1314
|
+
t = threading.Thread(
|
|
1315
|
+
target=_thread_filter,
|
|
1316
|
+
args=(prev_stdout, write_end, step.fn),
|
|
1317
|
+
daemon=True,
|
|
1318
|
+
)
|
|
1319
|
+
elif isinstance(step, MeterStep):
|
|
1320
|
+
t = threading.Thread(
|
|
1321
|
+
target=_thread_meter,
|
|
1322
|
+
args=(prev_stdout, write_end, step),
|
|
1323
|
+
daemon=True,
|
|
1324
|
+
)
|
|
1325
|
+
else:
|
|
1326
|
+
peek_fn = step.fn or _default_peek_fn(step.label)
|
|
1327
|
+
t = threading.Thread(
|
|
1328
|
+
target=_thread_peek,
|
|
1329
|
+
args=(prev_stdout, write_end, peek_fn),
|
|
1330
|
+
daemon=True,
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
t.start()
|
|
1334
|
+
except Exception:
|
|
1335
|
+
os.close(read_fd)
|
|
1336
|
+
os.close(write_fd)
|
|
1337
|
+
raise
|
|
1338
|
+
pg.add_thread(t)
|
|
1339
|
+
prev_stdout = read_end
|
|
1340
|
+
|
|
1341
|
+
first_proc_stdin = procs[0].stdin if procs else None
|
|
1342
|
+
if input_data and first_proc_stdin:
|
|
1343
|
+
|
|
1344
|
+
def _feed() -> None:
|
|
1345
|
+
try:
|
|
1346
|
+
first_proc_stdin.write(input_data) # type: ignore[union-attr]
|
|
1347
|
+
finally:
|
|
1348
|
+
first_proc_stdin.close() # type: ignore[union-attr]
|
|
1349
|
+
|
|
1350
|
+
feed_thread = threading.Thread(target=_feed, daemon=True)
|
|
1351
|
+
feed_thread.start()
|
|
1352
|
+
pg.add_thread(feed_thread)
|
|
1353
|
+
elif first_proc_stdin:
|
|
1354
|
+
first_proc_stdin.close()
|
|
1355
|
+
|
|
1356
|
+
stdout = prev_stdout.read() if prev_stdout else ""
|
|
1357
|
+
if prev_stdout:
|
|
1358
|
+
prev_stdout.close()
|
|
1359
|
+
|
|
1360
|
+
# Wait for threads and processes in this group
|
|
1361
|
+
for t in pg._threads:
|
|
1362
|
+
t.join()
|
|
1363
|
+
|
|
1364
|
+
for proc in procs:
|
|
1365
|
+
proc.wait()
|
|
1366
|
+
if proc.stderr:
|
|
1367
|
+
err = proc.stderr.read()
|
|
1368
|
+
if err:
|
|
1369
|
+
all_stderr.append(err)
|
|
1370
|
+
|
|
1371
|
+
for idx, name in captures:
|
|
1372
|
+
if idx == len(procs) - 1:
|
|
1373
|
+
ctx[name] = stdout.strip()
|
|
1374
|
+
|
|
1375
|
+
for proc in procs:
|
|
1376
|
+
if proc.returncode != 0:
|
|
1377
|
+
if isinstance(proc.args, list):
|
|
1378
|
+
cmd_name = shlex.join([str(a) for a in proc.args])
|
|
1379
|
+
else:
|
|
1380
|
+
cmd_name = str(proc.args)
|
|
1381
|
+
all_stderr.append(f"{cmd_name}: {_explain_exit(proc.returncode)}")
|
|
1382
|
+
return stdout, proc.returncode, all_stderr, len(procs)
|
|
1383
|
+
|
|
1384
|
+
return stdout, 0, all_stderr, len(procs)
|
|
1385
|
+
|
|
1386
|
+
def _exec_single_cmd(
|
|
1387
|
+
self,
|
|
1388
|
+
step: CmdStep,
|
|
1389
|
+
input_data: str,
|
|
1390
|
+
ctx: dict[str, str],
|
|
1391
|
+
pg: ProcessGroup,
|
|
1392
|
+
) -> tuple[str, int, list[str]]:
|
|
1393
|
+
args = step.resolve_args(ctx)
|
|
1394
|
+
stderr_dst = subprocess.STDOUT if step.merge_stderr else subprocess.PIPE
|
|
1395
|
+
try:
|
|
1396
|
+
proc = pg.spawn(
|
|
1397
|
+
args,
|
|
1398
|
+
stdin=subprocess.PIPE,
|
|
1399
|
+
stdout=subprocess.PIPE,
|
|
1400
|
+
stderr=stderr_dst,
|
|
1401
|
+
text=True,
|
|
1402
|
+
)
|
|
1403
|
+
except FileNotFoundError:
|
|
1404
|
+
return "", 127, [f"{step.binary}: command not found"]
|
|
1405
|
+
stdout, stderr = proc.communicate(input=input_data or None)
|
|
1406
|
+
proc_result_stdout = stdout or ""
|
|
1407
|
+
proc_result_stderr = stderr or ""
|
|
1408
|
+
errs: list[str] = []
|
|
1409
|
+
if proc_result_stderr:
|
|
1410
|
+
errs.append(proc_result_stderr)
|
|
1411
|
+
if proc.returncode != 0:
|
|
1412
|
+
errs.append(f"{shlex.join(args)}: {_explain_exit(proc.returncode)}")
|
|
1413
|
+
if step.capture:
|
|
1414
|
+
ctx[step.capture] = proc_result_stdout.strip()
|
|
1415
|
+
return proc_result_stdout, proc.returncode, errs
|
|
1416
|
+
|
|
1417
|
+
# -- Each -----------------------------------------------------------------
|
|
1418
|
+
|
|
1419
|
+
def _exec_each(self, step: EachStep, input_data: str) -> tuple[str, int, list[str], int]:
|
|
1420
|
+
stripped = input_data.strip()
|
|
1421
|
+
lines = stripped.splitlines() if stripped else []
|
|
1422
|
+
if not lines:
|
|
1423
|
+
return "", 0, [], 0
|
|
1424
|
+
|
|
1425
|
+
if step.workers <= 1:
|
|
1426
|
+
out, rc, errs = self._exec_each_sequential(step, lines)
|
|
1427
|
+
return out, rc, errs, len(lines) if rc == 0 else 0
|
|
1428
|
+
|
|
1429
|
+
out, rc, errs, spawns = self._exec_each_parallel(step, lines)
|
|
1430
|
+
return out, rc, errs, spawns
|
|
1431
|
+
|
|
1432
|
+
def _exec_each_sequential(self, step: EachStep, lines: list[str]) -> tuple[str, int, list[str]]:
|
|
1433
|
+
parts: list[str] = []
|
|
1434
|
+
all_stderr: list[str] = []
|
|
1435
|
+
for line in lines:
|
|
1436
|
+
r = step.fn(line).run(validate=False)
|
|
1437
|
+
if r.stderr:
|
|
1438
|
+
all_stderr.append(r.stderr)
|
|
1439
|
+
if not r.ok:
|
|
1440
|
+
return r.stdout, r.returncode, all_stderr
|
|
1441
|
+
parts.append(r.stdout.rstrip("\n"))
|
|
1442
|
+
return "\n".join(parts) + "\n", 0, all_stderr
|
|
1443
|
+
|
|
1444
|
+
def _exec_each_parallel(
|
|
1445
|
+
self,
|
|
1446
|
+
step: EachStep,
|
|
1447
|
+
lines: list[str],
|
|
1448
|
+
) -> tuple[str, int, list[str], int]:
|
|
1449
|
+
results: list[Result | None] = [None] * len(lines)
|
|
1450
|
+
cancel = threading.Event()
|
|
1451
|
+
|
|
1452
|
+
def run_one(idx: int, line: str) -> None:
|
|
1453
|
+
if cancel.is_set():
|
|
1454
|
+
return
|
|
1455
|
+
r = step.fn(line).run(validate=False)
|
|
1456
|
+
results[idx] = r
|
|
1457
|
+
if not r.ok:
|
|
1458
|
+
cancel.set()
|
|
1459
|
+
|
|
1460
|
+
with ThreadPoolExecutor(max_workers=step.workers) as pool:
|
|
1461
|
+
futures = [pool.submit(run_one, i, line) for i, line in enumerate(lines)]
|
|
1462
|
+
for f in futures:
|
|
1463
|
+
f.result()
|
|
1464
|
+
|
|
1465
|
+
all_stderr: list[str] = []
|
|
1466
|
+
parts: list[str] = []
|
|
1467
|
+
spawns = 0
|
|
1468
|
+
for r in results:
|
|
1469
|
+
if r is None:
|
|
1470
|
+
break
|
|
1471
|
+
spawns += 1
|
|
1472
|
+
if r.stderr:
|
|
1473
|
+
all_stderr.append(r.stderr)
|
|
1474
|
+
if not r.ok:
|
|
1475
|
+
return r.stdout, r.returncode, all_stderr, spawns
|
|
1476
|
+
parts.append(r.stdout.rstrip("\n"))
|
|
1477
|
+
|
|
1478
|
+
return "\n".join(parts) + "\n", 0, all_stderr, spawns
|
|
1479
|
+
|
|
1480
|
+
# -- Redirect -------------------------------------------------------------
|
|
1481
|
+
|
|
1482
|
+
def _exec_redirect(self, step: RedirectStep, output: str) -> str:
|
|
1483
|
+
mode = "a" if step.append else "w"
|
|
1484
|
+
if step.stdout:
|
|
1485
|
+
with open(step.stdout, mode) as f:
|
|
1486
|
+
f.write(output)
|
|
1487
|
+
if step.stderr:
|
|
1488
|
+
with open(step.stderr, mode) as f:
|
|
1489
|
+
f.write(output)
|
|
1490
|
+
return output
|
|
1491
|
+
|
|
1492
|
+
|
|
1493
|
+
# -- Cmd ----------------------------------------------------------------------
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
class Cmd:
|
|
1497
|
+
def __init__(self, binary: str = "", *args: str) -> None:
|
|
1498
|
+
self._binary = binary
|
|
1499
|
+
self._args = list(args)
|
|
1500
|
+
self._version: str | None = None
|
|
1501
|
+
self._on_fail: OnFail = "error"
|
|
1502
|
+
self._msg: str = ""
|
|
1503
|
+
|
|
1504
|
+
def __call__(self, *args: str) -> Cmd:
|
|
1505
|
+
cmd = Cmd(self._binary, *self._args, *args)
|
|
1506
|
+
cmd._version = self._version
|
|
1507
|
+
cmd._on_fail = self._on_fail
|
|
1508
|
+
cmd._msg = self._msg
|
|
1509
|
+
return cmd
|
|
1510
|
+
|
|
1511
|
+
def _as_step(self, capture: str | None = None) -> CmdStep:
|
|
1512
|
+
return CmdStep(
|
|
1513
|
+
self._binary,
|
|
1514
|
+
list(self._args),
|
|
1515
|
+
capture=capture,
|
|
1516
|
+
version=self._version,
|
|
1517
|
+
on_fail=self._on_fail,
|
|
1518
|
+
msg=self._msg,
|
|
1519
|
+
)
|
|
1520
|
+
|
|
1521
|
+
def _to_pipeline(self, *extra: Step) -> Pipeline:
|
|
1522
|
+
return Pipeline([self._as_step(), *extra])
|
|
1523
|
+
|
|
1524
|
+
def pipe(self, cmd: Cmd) -> Pipeline:
|
|
1525
|
+
return self._to_pipeline(cmd._as_step())
|
|
1526
|
+
|
|
1527
|
+
def filter(self, fn: Callable[[str], bool]) -> Pipeline:
|
|
1528
|
+
return self._to_pipeline(FilterStep(fn))
|
|
1529
|
+
|
|
1530
|
+
def each(self, fn: Callable[[str], Cmd], workers: int = 1) -> Pipeline:
|
|
1531
|
+
return self._to_pipeline(EachStep(fn, workers=workers))
|
|
1532
|
+
|
|
1533
|
+
def peek(self, label: str | None = None, fn: Callable[[str], None] | None = None) -> Pipeline:
|
|
1534
|
+
return self._to_pipeline(PeekStep(label=label, fn=fn))
|
|
1535
|
+
|
|
1536
|
+
def meter(
|
|
1537
|
+
self,
|
|
1538
|
+
label: str | None = None,
|
|
1539
|
+
to: Callable[[MeterStats], None] | IO[str] | None = None,
|
|
1540
|
+
interval: float = 1.0,
|
|
1541
|
+
) -> Pipeline:
|
|
1542
|
+
return self._to_pipeline(MeterStep(label=label, to=to, interval=interval))
|
|
1543
|
+
|
|
1544
|
+
def collect(self) -> Pipeline:
|
|
1545
|
+
return self._to_pipeline(CollectStep())
|
|
1546
|
+
|
|
1547
|
+
def capture(self, name: str) -> Pipeline:
|
|
1548
|
+
return self._to_pipeline(CaptureStep(name))
|
|
1549
|
+
|
|
1550
|
+
def merge_stderr(self) -> Pipeline:
|
|
1551
|
+
step = self._as_step()
|
|
1552
|
+
step.merge_stderr = True
|
|
1553
|
+
return Pipeline([step])
|
|
1554
|
+
|
|
1555
|
+
def redirect(
|
|
1556
|
+
self,
|
|
1557
|
+
stdout: str | None = None,
|
|
1558
|
+
stderr: str | None = None,
|
|
1559
|
+
append: bool = False,
|
|
1560
|
+
) -> Pipeline:
|
|
1561
|
+
return self._to_pipeline(RedirectStep(stdout=stdout, stderr=stderr, append=append))
|
|
1562
|
+
|
|
1563
|
+
def feed(self, data: str) -> Pipeline:
|
|
1564
|
+
return Pipeline([FeedStep(data), self._as_step()])
|
|
1565
|
+
|
|
1566
|
+
def from_file(self, path: str, block_size: int = 0) -> Pipeline:
|
|
1567
|
+
return Pipeline([FromFileStep(path, block_size=block_size), self._as_step()])
|
|
1568
|
+
|
|
1569
|
+
def run(
|
|
1570
|
+
self,
|
|
1571
|
+
validate: bool = True,
|
|
1572
|
+
pre: list[Callable[[], CheckResult]] | None = None,
|
|
1573
|
+
) -> Result:
|
|
1574
|
+
return Pipeline([self._as_step()]).run(
|
|
1575
|
+
validate=validate,
|
|
1576
|
+
pre=pre,
|
|
1577
|
+
)
|
|
1578
|
+
|
|
1579
|
+
def check(self) -> list[CheckResult]:
|
|
1580
|
+
return Pipeline([self._as_step()]).check()
|
|
1581
|
+
|
|
1582
|
+
def __repr__(self) -> str:
|
|
1583
|
+
return f"Cmd({shlex.join([self._binary, *self._args])})"
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
# -- tool() factory -----------------------------------------------------------
|
|
1587
|
+
|
|
1588
|
+
|
|
1589
|
+
def tool(
|
|
1590
|
+
name: str,
|
|
1591
|
+
version: str | None = None,
|
|
1592
|
+
on_fail: OnFail = "error",
|
|
1593
|
+
msg: str = "",
|
|
1594
|
+
) -> Cmd:
|
|
1595
|
+
cmd = Cmd(name)
|
|
1596
|
+
cmd._version = version
|
|
1597
|
+
cmd._on_fail = on_fail
|
|
1598
|
+
cmd._msg = msg
|
|
1599
|
+
return cmd
|