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 ADDED
@@ -0,0 +1,29 @@
1
+ """clichain — Fluent CLI tool chaining for Python."""
2
+
3
+ from clichain.checks import CheckResult, env, file_exists
4
+ from clichain.core import (
5
+ Cmd,
6
+ MeterStats,
7
+ Pipeline,
8
+ Result,
9
+ StepProfile,
10
+ _version,
11
+ set_output,
12
+ tool,
13
+ )
14
+
15
+ __version__ = _version
16
+
17
+ __all__ = [
18
+ "CheckResult",
19
+ "Cmd",
20
+ "MeterStats",
21
+ "Pipeline",
22
+ "Result",
23
+ "StepProfile",
24
+ "__version__",
25
+ "env",
26
+ "file_exists",
27
+ "set_output",
28
+ "tool",
29
+ ]
clichain/checks.py ADDED
@@ -0,0 +1,177 @@
1
+ """Preflight checks: binary, env, file_exists, and custom checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import shutil
8
+ import subprocess
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from typing import Literal
12
+
13
+ OnFail = Literal["error", "warn", "pass"]
14
+
15
+
16
+ @dataclass
17
+ class CheckResult:
18
+ ok: bool
19
+ name: str
20
+ expected: str
21
+ found: str
22
+ msg: str
23
+ on_fail: OnFail
24
+
25
+ @property
26
+ def should_stop(self) -> bool:
27
+ return not self.ok and self.on_fail == "error"
28
+
29
+ @property
30
+ def should_report(self) -> bool:
31
+ if self.ok:
32
+ return True
33
+ return self.on_fail != "pass"
34
+
35
+ def format(self) -> str:
36
+ if self.ok:
37
+ return f" ok: {self.name} ({self.found})"
38
+ label = "error" if self.on_fail == "error" else "warn"
39
+ if self.msg:
40
+ return f" {label}: {self.name} — {self.msg}"
41
+ return f" {label}: {self.name} — expected {self.expected}, found {self.found}"
42
+
43
+
44
+ def _detect_version(binary: str) -> str | None:
45
+ path = shutil.which(binary)
46
+ if not path:
47
+ return None
48
+ for flag in ["--version", "-version", "version", "-v"]:
49
+ try:
50
+ proc = subprocess.run(
51
+ [path, flag],
52
+ capture_output=True,
53
+ text=True,
54
+ timeout=5,
55
+ )
56
+ text = proc.stdout + proc.stderr
57
+ match = re.search(r"(\d+\.\d+(?:\.\d+)?)", text)
58
+ if match:
59
+ return match.group(1)
60
+ except (subprocess.TimeoutExpired, OSError):
61
+ continue
62
+ return None
63
+
64
+
65
+ def _version_satisfies(found: str, constraint: str) -> bool:
66
+ """Check simple version constraints like >=1.7, <3.0, >=6.0,<8.0."""
67
+ found_parts = [int(x) for x in found.split(".")]
68
+
69
+ for part in constraint.split(","):
70
+ part = part.strip()
71
+ if part.startswith(">="):
72
+ req = [int(x) for x in part[2:].split(".")]
73
+ if found_parts < req:
74
+ return False
75
+ elif part.startswith("<="):
76
+ req = [int(x) for x in part[2:].split(".")]
77
+ if found_parts > req:
78
+ return False
79
+ elif part.startswith(">"):
80
+ req = [int(x) for x in part[1:].split(".")]
81
+ if found_parts <= req:
82
+ return False
83
+ elif part.startswith("<"):
84
+ req = [int(x) for x in part[1:].split(".")]
85
+ if found_parts >= req:
86
+ return False
87
+ elif part.startswith("=="):
88
+ req = [int(x) for x in part[2:].split(".")]
89
+ if found_parts != req:
90
+ return False
91
+ return True
92
+
93
+
94
+ def binary_check(
95
+ name: str,
96
+ version: str | None = None,
97
+ on_fail: OnFail = "error",
98
+ msg: str = "",
99
+ ) -> Callable[[], CheckResult]:
100
+ def _check() -> CheckResult:
101
+ path = shutil.which(name)
102
+ if not path:
103
+ return CheckResult(
104
+ ok=False,
105
+ name=name,
106
+ expected="on $PATH" + (f" {version}" if version else ""),
107
+ found="not found",
108
+ msg=msg or f"{name} not found on $PATH",
109
+ on_fail=on_fail,
110
+ )
111
+
112
+ if not version:
113
+ return CheckResult(
114
+ ok=True,
115
+ name=name,
116
+ expected="on $PATH",
117
+ found=f"{path}",
118
+ msg=msg,
119
+ on_fail=on_fail,
120
+ )
121
+
122
+ detected = _detect_version(name)
123
+ if not detected:
124
+ return CheckResult(
125
+ ok=True,
126
+ name=name,
127
+ expected=version,
128
+ found=f"{path} (version unknown)",
129
+ msg=msg,
130
+ on_fail=on_fail,
131
+ )
132
+
133
+ satisfied = _version_satisfies(detected, version)
134
+ return CheckResult(
135
+ ok=satisfied,
136
+ name=name,
137
+ expected=version,
138
+ found=f"{detected} at {path}",
139
+ msg=msg or (f"{name} {detected} does not satisfy {version}" if not satisfied else ""),
140
+ on_fail=on_fail,
141
+ )
142
+
143
+ return _check
144
+
145
+
146
+ def env(name: str, on_fail: OnFail = "error", msg: str = "") -> Callable[[], CheckResult]:
147
+ def _check() -> CheckResult:
148
+ value = os.environ.get(name)
149
+ return CheckResult(
150
+ ok=value is not None,
151
+ name=f"env:{name}",
152
+ expected="set",
153
+ found="set" if value is not None else "not set",
154
+ msg=msg or f"environment variable {name} is not set",
155
+ on_fail=on_fail,
156
+ )
157
+
158
+ return _check
159
+
160
+
161
+ def file_exists(path: str, on_fail: OnFail = "error", msg: str = "") -> Callable[[], CheckResult]:
162
+ def _check() -> CheckResult:
163
+ exists = os.path.exists(path)
164
+ return CheckResult(
165
+ ok=exists,
166
+ name=f"file:{path}",
167
+ expected="exists",
168
+ found="exists" if exists else f"not found (cwd: {os.getcwd()})",
169
+ msg=msg or f"file {path} not found",
170
+ on_fail=on_fail,
171
+ )
172
+
173
+ return _check
174
+
175
+
176
+ def run_checks(checks: list[Callable[[], CheckResult]]) -> list[CheckResult]:
177
+ return [check() for check in checks]
clichain/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """clichain CLI — compile, explain, check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+
9
+ def cmd_explain(args: argparse.Namespace) -> int:
10
+ from clichain.core import _ERROR_DETAIL, _EXIT_CODES, _SIGNALS
11
+
12
+ code = args.code.upper()
13
+
14
+ if code.startswith("S"):
15
+ sig = int(code[1:])
16
+ if sig in _SIGNALS:
17
+ name, desc = _SIGNALS[sig]
18
+ print(f"error[{code}]: signal {sig} ({name})")
19
+ print(f" {desc}")
20
+ else:
21
+ print(f"error[{code}]: signal {sig}")
22
+ print(" unknown signal")
23
+ elif code.startswith("X"):
24
+ exit_code = int(code[1:])
25
+ if exit_code in _EXIT_CODES:
26
+ print(f"error[{code}]: exit {exit_code}")
27
+ print(f" {_EXIT_CODES[exit_code]}")
28
+ else:
29
+ print(f"error[{code}]: exit {exit_code}")
30
+ print(" unknown exit code")
31
+ else:
32
+ print(f"unknown error code: {code}")
33
+ return 1
34
+
35
+ detail = _ERROR_DETAIL.get(code)
36
+ if detail:
37
+ print()
38
+ print(f" {detail}")
39
+
40
+ return 0
41
+
42
+
43
+ def cmd_check(args: argparse.Namespace) -> int:
44
+ import importlib.util
45
+
46
+ from clichain.core import Pipeline, set_output
47
+
48
+ set_output(sys.stderr)
49
+
50
+ spec = importlib.util.spec_from_file_location("__clichain_script__", args.script)
51
+ if not spec or not spec.loader:
52
+ print(f"error: cannot load {args.script}", file=sys.stderr)
53
+ return 1
54
+
55
+ # Import the script — this will create tool() instances
56
+ # but won't run pipelines unless they call .run() at module level
57
+ module = importlib.util.module_from_spec(spec)
58
+
59
+ # Collect all Cmd and Pipeline objects after import
60
+ import contextlib
61
+
62
+ with contextlib.suppress(SystemExit):
63
+ spec.loader.exec_module(module)
64
+
65
+ # Find all Pipeline/Cmd objects in the module and check them
66
+ from clichain.core import Cmd
67
+
68
+ checked = False
69
+ for name in dir(module):
70
+ obj = getattr(module, name)
71
+ if isinstance(obj, Pipeline):
72
+ print(f"pipeline: {name}", file=sys.stderr)
73
+ obj.check()
74
+ checked = True
75
+ elif isinstance(obj, Cmd):
76
+ print(f"tool: {name} ({obj._binary})", file=sys.stderr)
77
+ obj.check()
78
+ checked = True
79
+
80
+ if not checked:
81
+ print("no pipelines or tools found in script", file=sys.stderr)
82
+
83
+ return 0
84
+
85
+
86
+ def cmd_compile(args: argparse.Namespace) -> int:
87
+ import importlib.util
88
+
89
+ if importlib.util.find_spec("PyInstaller") is None:
90
+ print(
91
+ "error: pyinstaller not installed\n install with: pip install cmdchain[compile]",
92
+ file=sys.stderr,
93
+ )
94
+ return 1
95
+
96
+ from PyInstaller import __main__ as pyinstaller_main # type: ignore[import-not-found]
97
+
98
+ script = args.script
99
+ output = args.output or script.rsplit(".", 1)[0]
100
+
101
+ pyinstaller_args = [
102
+ script,
103
+ "--onefile",
104
+ "--name",
105
+ output,
106
+ "--noconfirm",
107
+ ]
108
+
109
+ if args.clean:
110
+ pyinstaller_args.append("--clean")
111
+
112
+ print(f"compiling {script} -> {output}")
113
+ pyinstaller_main.run(pyinstaller_args)
114
+
115
+ return 0
116
+
117
+
118
+ def main() -> None:
119
+ parser = argparse.ArgumentParser(
120
+ prog="clichain",
121
+ description="CLI tool chaining for Python",
122
+ )
123
+ sub = parser.add_subparsers(dest="command")
124
+
125
+ # explain
126
+ p_explain = sub.add_parser("explain", help="explain an error code")
127
+ p_explain.add_argument("code", help="error code (e.g. S13, X127)")
128
+
129
+ # check
130
+ p_check = sub.add_parser("check", help="preflight a script")
131
+ p_check.add_argument("script", help="path to .py script")
132
+
133
+ # compile
134
+ p_compile = sub.add_parser("compile", help="compile script to binary")
135
+ p_compile.add_argument("script", help="path to .py script")
136
+ p_compile.add_argument("-o", "--output", help="output binary name")
137
+ p_compile.add_argument("--clean", action="store_true", help="clean build artifacts")
138
+
139
+ args = parser.parse_args()
140
+
141
+ if args.command is None:
142
+ parser.print_help()
143
+ sys.exit(0)
144
+
145
+ handlers = {
146
+ "explain": cmd_explain,
147
+ "check": cmd_check,
148
+ "compile": cmd_compile,
149
+ }
150
+
151
+ sys.exit(handlers[args.command](args))