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