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/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