cli-to-py 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.
cli_to_py/__init__.py ADDED
@@ -0,0 +1,113 @@
1
+ """cli_to_py — Turn any CLI into a Python API, automatically.
2
+
3
+ Public API:
4
+
5
+ from cli_to_py import convert, convert_sync, from_help_text
6
+
7
+ # Async
8
+ git = await convert("git")
9
+ result = await git.status(short=True)
10
+ branch = await git.branch(show_current=True).text()
11
+ errors = git.validate("commit", massage="x") # returns [ValidationError]
12
+
13
+ # Sync
14
+ git = convert_sync("git")
15
+ result = git.status(short=True)
16
+
17
+ # Static help text
18
+ api = from_help_text("my-tool", help_text)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from .api import CliApi
24
+ from .cache import cache_dir, clear_cache, load_cached_schema, save_cached_schema
25
+ from .command_future import CommandFuture
26
+ from .command_string import to_command_string
27
+ from .convert import convert, convert_sync, from_help_text, from_help_text_sync
28
+ from .exec import (
29
+ BinaryNotFoundError,
30
+ CommandAborted,
31
+ CommandProcess,
32
+ CommandTimeout,
33
+ run_command,
34
+ run_for_help,
35
+ spawn_command,
36
+ )
37
+ from .generate import generate_json, generate_stub, generate_wrapper
38
+ from .load_schema import load_schema, load_schema_sync
39
+ from .options_to_args import options_to_args
40
+ from .parse_help import parse_help_text, strip_ansi
41
+ from .parse_subcommands import enrich_subcommands, parse_subcommand_help
42
+ from .run_config import RunConfig
43
+ from .schema import (
44
+ CliSchema,
45
+ CommandResult,
46
+ ParsedCommand,
47
+ ParsedFlag,
48
+ ParsedPositionalArg,
49
+ ParsedSubcommand,
50
+ )
51
+ from .script import Script, script
52
+ from .sync_api import SyncCliApi
53
+ from .sync_exec import run_command_sync, run_for_help_sync
54
+ from .validate import ValidationError, validate_options
55
+
56
+ __version__ = "0.1.0"
57
+
58
+ __all__ = [
59
+ # version
60
+ "__version__",
61
+ # entry points
62
+ "convert",
63
+ "convert_sync",
64
+ "from_help_text",
65
+ "from_help_text_sync",
66
+ "load_schema",
67
+ "load_schema_sync",
68
+ # api objects
69
+ "CliApi",
70
+ "SyncCliApi",
71
+ "CommandFuture",
72
+ "CommandProcess",
73
+ # schema types
74
+ "CliSchema",
75
+ "ParsedCommand",
76
+ "ParsedSubcommand",
77
+ "ParsedFlag",
78
+ "ParsedPositionalArg",
79
+ "CommandResult",
80
+ # config
81
+ "RunConfig",
82
+ # validation
83
+ "validate_options",
84
+ "ValidationError",
85
+ # execution
86
+ "run_command",
87
+ "run_command_sync",
88
+ "run_for_help",
89
+ "run_for_help_sync",
90
+ "spawn_command",
91
+ "CommandTimeout",
92
+ "CommandAborted",
93
+ "BinaryNotFoundError",
94
+ # parsing
95
+ "parse_help_text",
96
+ "strip_ansi",
97
+ "parse_subcommand_help",
98
+ "enrich_subcommands",
99
+ "options_to_args",
100
+ "to_command_string",
101
+ # codegen
102
+ "generate_wrapper",
103
+ "generate_stub",
104
+ "generate_json",
105
+ # script chaining
106
+ "Script",
107
+ "script",
108
+ # cache
109
+ "cache_dir",
110
+ "load_cached_schema",
111
+ "save_cached_schema",
112
+ "clear_cache",
113
+ ]
cli_to_py/_api_base.py ADDED
@@ -0,0 +1,112 @@
1
+ """Shared plumbing for CliApi (async) and SyncCliApi (sync).
2
+
3
+ These helpers were duplicated across api.py and sync_api.py byte-for-byte.
4
+ Extracted here so a change to alias resolution or kwargs handling lands
5
+ in exactly one place.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from .case import kebab_to_snake, snake_to_kebab
13
+ from .run_config import RunConfig
14
+ from .schema import CliSchema, ParsedSubcommand
15
+
16
+
17
+ class _BaseCliApi:
18
+ """Base class holding dispatch/resolution plumbing shared by CliApi and SyncCliApi.
19
+
20
+ Subclasses own `__call__` and `__getattr__` semantics (async vs sync) but
21
+ share alias resolution, equals-flag collection, config merging, and the
22
+ `_config` kwarg extraction convention.
23
+ """
24
+
25
+ binary_name: str
26
+ schema: CliSchema
27
+ _default_config: RunConfig
28
+ _equals_flags: set[str]
29
+
30
+ def __init__(
31
+ self,
32
+ binary_name: str,
33
+ schema: CliSchema,
34
+ default_config: RunConfig | None = None,
35
+ ):
36
+ self.binary_name = binary_name
37
+ self.schema = schema
38
+ self._default_config = default_config or RunConfig()
39
+ self._equals_flags = {
40
+ kebab_to_snake(f.long_name) for f in schema.command.flags if f.uses_equals
41
+ }
42
+
43
+ # --- resolution helpers
44
+
45
+ def _resolve_alias(self, name: str) -> str:
46
+ normalized = snake_to_kebab(name)
47
+ for sub in self.schema.command.subcommands:
48
+ if (
49
+ sub.name == name
50
+ or sub.name == normalized
51
+ or (sub.aliases and (name in sub.aliases or normalized in sub.aliases))
52
+ ):
53
+ return sub.name
54
+ return name
55
+
56
+ def _find_subcommand(self, name: str) -> ParsedSubcommand | None:
57
+ normalized = snake_to_kebab(name)
58
+ for sub in self.schema.command.subcommands:
59
+ if (
60
+ sub.name == name
61
+ or sub.name == normalized
62
+ or (sub.aliases and (name in sub.aliases or normalized in sub.aliases))
63
+ ):
64
+ return sub
65
+ return None
66
+
67
+ def _equals_for(self, resolved_sub: str) -> set[str]:
68
+ sub = self._find_subcommand(resolved_sub)
69
+ if sub is None or not sub.flags:
70
+ return self._equals_flags
71
+ sub_equals = {kebab_to_snake(f.long_name) for f in sub.flags if f.uses_equals}
72
+ return self._equals_flags | sub_equals
73
+
74
+ def _split_kwargs(
75
+ self, kwargs: dict[str, Any]
76
+ ) -> tuple[dict[str, Any], RunConfig | None]:
77
+ """Extract an optional `_config` kwarg (a RunConfig) from user options.
78
+
79
+ Also guards against the common typo where a user passes
80
+ `config=RunConfig(...)` instead of `_config=RunConfig(...)` — without
81
+ the underscore it would serialize to argv as `--config <repr>`, which
82
+ is never what the user intended.
83
+ """
84
+ config = kwargs.pop("_config", None)
85
+ if config is not None and not isinstance(config, RunConfig):
86
+ raise TypeError("_config must be a RunConfig instance")
87
+ # Stray RunConfig values under non-underscore keys are almost always typos.
88
+ for key, value in kwargs.items():
89
+ if isinstance(value, RunConfig):
90
+ raise TypeError(
91
+ f"kwarg {key!r} holds a RunConfig — did you mean `_config=...`?"
92
+ )
93
+ return kwargs, config
94
+
95
+ def _merged_config(self, per_call: RunConfig | None) -> RunConfig:
96
+ return self._default_config.merge(per_call)
97
+
98
+ # --- introspection helpers
99
+
100
+ def _subcommand_names(self) -> list[str]:
101
+ """Return known subcommand names and aliases, without duplicates."""
102
+ names: list[str] = []
103
+ seen: set[str] = set()
104
+ for sub in self.schema.command.subcommands:
105
+ if sub.name not in seen:
106
+ names.append(sub.name)
107
+ seen.add(sub.name)
108
+ for alias in sub.aliases or ():
109
+ if alias not in seen:
110
+ names.append(alias)
111
+ seen.add(alias)
112
+ return names
cli_to_py/api.py ADDED
@@ -0,0 +1,159 @@
1
+ """Public CliApi class — async Pythonic wrapper around a CLI binary.
2
+
3
+ Pythonic design: `__getattr__` + `__call__` replace JS's Proxy. Ordinary
4
+ attributes (binary_name, schema, helper methods) are resolved normally;
5
+ only attribute names that match a parsed subcommand get routed through
6
+ `__getattr__` to a dynamic dispatcher. Unknown attribute names raise
7
+ AttributeError (so `hasattr(api, 'nonexistent')` is False and IDE
8
+ introspection works).
9
+
10
+ Usage:
11
+ api = await convert("git")
12
+ await api.status(short=True)
13
+ await api("diff", name_only=True, _=["HEAD~1"])
14
+ await api.branch(show_current=True).text()
15
+
16
+ api.schema # parsed schema
17
+ api.validate("commit", massage="x") # typo catcher
18
+ api.command_string("commit", message="x") # shell string
19
+ proc = await api.spawn("log", oneline=True) # streaming
20
+ async for line in proc: ...
21
+
22
+ Reserved method names (can't reach a same-named subcommand via dot
23
+ notation): `validate`, `command_string`, `spawn`, `parse`, `schema`,
24
+ `binary_name`. If your CLI has a subcommand named (e.g.) `validate`,
25
+ use `api("validate", ...)` or `api.__call__("validate", ...)` instead.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Any
31
+
32
+ from ._api_base import _BaseCliApi
33
+ from .command_future import CommandFuture
34
+ from .command_string import to_command_string
35
+ from .exec import CommandProcess, run_command, spawn_command
36
+ from .parse_subcommands import parse_subcommand_help
37
+ from .schema import ParsedCommand, ParsedSubcommand
38
+ from .validate import ValidationError, validate_options
39
+
40
+
41
+ class CliApi(_BaseCliApi):
42
+ """Async, callable Pythonic wrapper around a CLI binary."""
43
+
44
+ def __call__(self, subcommand: str | None = None, /, **kwargs: Any) -> CommandFuture:
45
+ options, per_call = self._split_kwargs(kwargs)
46
+ if subcommand is not None:
47
+ resolved = self._resolve_alias(subcommand)
48
+ equals = self._equals_for(resolved)
49
+ return CommandFuture(run_command(
50
+ self.binary_name, [resolved], options,
51
+ self._merged_config(per_call), equals,
52
+ ))
53
+ return CommandFuture(run_command(
54
+ self.binary_name, [], options,
55
+ self._merged_config(per_call), self._equals_flags,
56
+ ))
57
+
58
+ def __getattr__(self, name: str) -> Any:
59
+ # Only called if normal attribute lookup fails — so class methods
60
+ # like `validate`, `spawn`, `command_string`, `parse` still win.
61
+ if name.startswith("_"):
62
+ raise AttributeError(name)
63
+ if self._find_subcommand(name) is None:
64
+ raise AttributeError(
65
+ f"{type(self).__name__!s} has no subcommand {name!r}. "
66
+ f"Use api({name!r}, ...) if your CLI exposes it but --help didn't list it."
67
+ )
68
+ resolved = self._resolve_alias(name)
69
+ equals = self._equals_for(resolved)
70
+
71
+ def dispatch(**kwargs: Any) -> CommandFuture:
72
+ options, per_call = self._split_kwargs(kwargs)
73
+ return CommandFuture(run_command(
74
+ self.binary_name, [resolved], options,
75
+ self._merged_config(per_call), equals,
76
+ ))
77
+ dispatch.__name__ = name
78
+ return dispatch
79
+
80
+ def __dir__(self) -> list[str]:
81
+ """Expose known subcommands + helper methods for IDEs / dir()."""
82
+ base = set(super().__dir__())
83
+ base.update(self._subcommand_names())
84
+ return sorted(base)
85
+
86
+ # ------------------------------------------------------------------ helpers
87
+
88
+ def validate(
89
+ self, subcommand: str | None = None, /, **options: Any
90
+ ) -> list[ValidationError]:
91
+ if subcommand is None:
92
+ return validate_options(self.schema.command, options)
93
+ sub = self._find_subcommand(subcommand)
94
+ if sub is None:
95
+ raise ValueError(
96
+ f'Unknown subcommand "{subcommand}". Pass subcommands=True to convert().'
97
+ )
98
+ if sub.flags is None:
99
+ raise ValueError(
100
+ f'Subcommand "{subcommand}" not enriched. Call parse("{subcommand}") first '
101
+ f'or pass subcommands=True to convert().'
102
+ )
103
+ fake_command = ParsedCommand(
104
+ name=sub.name,
105
+ description=sub.description,
106
+ flags=sub.flags,
107
+ positional_args=sub.positional_args or [],
108
+ )
109
+ return validate_options(fake_command, options)
110
+
111
+ def command_string(self, subcommand: str | None = None, /, **options: Any) -> str:
112
+ if subcommand is None:
113
+ return to_command_string(self.binary_name, [], options, self._equals_flags)
114
+ resolved = self._resolve_alias(subcommand)
115
+ return to_command_string(
116
+ self.binary_name, [resolved], options, self._equals_for(resolved)
117
+ )
118
+
119
+ async def spawn(
120
+ self, subcommand: str | None = None, /, **kwargs: Any
121
+ ) -> CommandProcess:
122
+ options, per_call = self._split_kwargs(kwargs)
123
+ subs = [self._resolve_alias(subcommand)] if subcommand else []
124
+ equals = self._equals_for(subs[0]) if subs else self._equals_flags
125
+ return await spawn_command(
126
+ self.binary_name, subs, options, self._merged_config(per_call), equals
127
+ )
128
+
129
+ async def parse(self, subcommand_name: str | None = None) -> ParsedCommand | None:
130
+ """Lazily enrich one subcommand (or all) by re-running its --help."""
131
+ if subcommand_name is None:
132
+ from .parse_subcommands import enrich_subcommands
133
+ await enrich_subcommands(
134
+ self.binary_name, self.schema,
135
+ timeout=self._default_config.resolved_timeout(),
136
+ cwd=self._default_config.cwd,
137
+ env=self._default_config.env,
138
+ )
139
+ return None
140
+ parsed = await parse_subcommand_help(
141
+ self.binary_name, subcommand_name,
142
+ timeout=self._default_config.resolved_timeout(),
143
+ cwd=self._default_config.cwd,
144
+ env=self._default_config.env,
145
+ )
146
+ if parsed is not None:
147
+ existing = self._find_subcommand(subcommand_name)
148
+ if existing is not None:
149
+ existing.flags = parsed.flags
150
+ existing.positional_args = parsed.positional_args
151
+ else:
152
+ self.schema.command.subcommands.append(ParsedSubcommand(
153
+ name=subcommand_name,
154
+ aliases=[],
155
+ description=parsed.description,
156
+ flags=parsed.flags,
157
+ positional_args=parsed.positional_args,
158
+ ))
159
+ return parsed
cli_to_py/best_help.py ADDED
@@ -0,0 +1,45 @@
1
+ """Pick the better of stdout / stderr when asking a binary for its help text.
2
+
3
+ Some tools print --help to stdout (GNU convention), some to stderr.
4
+ Score each by counting "usage/options/commands" signal patterns, pick the
5
+ winner. If they tie, prefer the longer one. If both are empty, return "".
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ HELP_SIGNAL_PATTERNS = [
13
+ re.compile(r"usage:", re.IGNORECASE),
14
+ re.compile(r"options:", re.IGNORECASE),
15
+ re.compile(r"commands:", re.IGNORECASE),
16
+ re.compile(r"^\s+-", re.MULTILINE),
17
+ ]
18
+
19
+
20
+ def _score(text: str) -> int:
21
+ return sum(1 for pattern in HELP_SIGNAL_PATTERNS if pattern.search(text))
22
+
23
+
24
+ def select_help_output(stdout: str, stderr: str) -> str:
25
+ trimmed_out = stdout.strip()
26
+ trimmed_err = stderr.strip()
27
+
28
+ if not trimmed_out and not trimmed_err:
29
+ return ""
30
+ if not trimmed_err:
31
+ return stdout
32
+ if not trimmed_out:
33
+ return stderr
34
+
35
+ out_signals = _score(trimmed_out)
36
+ err_signals = _score(trimmed_err)
37
+
38
+ if out_signals and not err_signals:
39
+ return stdout
40
+ if err_signals and not out_signals:
41
+ return stderr
42
+ if out_signals and err_signals:
43
+ return stdout if out_signals >= err_signals else stderr
44
+
45
+ return stdout if len(trimmed_out) >= len(trimmed_err) else stderr
cli_to_py/cache.py ADDED
@@ -0,0 +1,165 @@
1
+ """Persistent schema cache for parsed CLI help text.
2
+
3
+ Rationale: parsing --help + enriching N subcommands costs ~300-1500ms per
4
+ binary. For frequently-used binaries (git, uv, kubectl), that adds up.
5
+ Cache the parsed CliSchema as JSON in ~/.cache/cli-to-py/, keyed by the
6
+ binary's absolute path + mtime, so cache invalidates when the binary is
7
+ upgraded.
8
+
9
+ The cache is optional and opt-in. Pass use_cache=True to convert() / convert_sync().
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import shutil
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from .constants import CACHE_DEFAULT_SUBDIR, CACHE_DIR_ENV, CACHE_SCHEMA_VERSION
23
+ from .schema import CliSchema, ParsedCommand, ParsedFlag, ParsedPositionalArg, ParsedSubcommand
24
+
25
+
26
+ def cache_dir() -> Path:
27
+ """Return the cache directory (respects CLI_TO_PY_CACHE_DIR env override)."""
28
+ override = os.environ.get(CACHE_DIR_ENV)
29
+ if override:
30
+ return Path(override)
31
+ xdg = os.environ.get("XDG_CACHE_HOME")
32
+ if xdg:
33
+ return Path(xdg) / CACHE_DEFAULT_SUBDIR
34
+ return Path.home() / ".cache" / CACHE_DEFAULT_SUBDIR
35
+
36
+
37
+ def _resolve_binary(binary_name: str) -> Path | None:
38
+ """Resolve binary to absolute path via shutil.which (or None if not found)."""
39
+ found = shutil.which(binary_name)
40
+ return Path(found) if found else None
41
+
42
+
43
+ def _cache_key(binary_name: str, *, subcommands: bool = True) -> str:
44
+ """Build a cache key: hash of (absolute path, mtime, name, subcommands scope)."""
45
+ resolved = _resolve_binary(binary_name)
46
+ scope = "full" if subcommands else "root"
47
+ if resolved is None:
48
+ return hashlib.sha1(f"{binary_name}|{scope}".encode()).hexdigest()[:16]
49
+ try:
50
+ mtime = int(resolved.stat().st_mtime)
51
+ except OSError:
52
+ mtime = 0
53
+ raw = f"{binary_name}|{resolved}|{mtime}|{scope}"
54
+ return hashlib.sha1(raw.encode()).hexdigest()[:16]
55
+
56
+
57
+ def _cache_path(binary_name: str, *, subcommands: bool = True) -> Path:
58
+ """Build a cache file path.
59
+
60
+ The binary_name may contain path separators (e.g. '/usr/local/bin/uv' or
61
+ './node_modules/.bin/foo'); strip them to a safe basename before embedding
62
+ in the filename. Uniqueness comes from the hash, not the label.
63
+ """
64
+ safe_label = os.path.basename(binary_name).replace(os.sep, "_")
65
+ if os.altsep:
66
+ safe_label = safe_label.replace(os.altsep, "_")
67
+ if not safe_label:
68
+ safe_label = "bin"
69
+ return cache_dir() / f"{safe_label}-{_cache_key(binary_name, subcommands=subcommands)}.json"
70
+
71
+
72
+ # ------------------------------------------------------------------ serialization
73
+
74
+ def _schema_to_dict(schema: CliSchema) -> dict[str, Any]:
75
+ return {
76
+ "version": CACHE_SCHEMA_VERSION,
77
+ "binary_name": schema.binary_name,
78
+ "command": dataclasses.asdict(schema.command),
79
+ }
80
+
81
+
82
+ def _dict_to_command(d: dict[str, Any]) -> ParsedCommand:
83
+ return ParsedCommand(
84
+ name=d["name"],
85
+ description=d.get("description", ""),
86
+ flags=[ParsedFlag(**f) for f in d.get("flags", [])],
87
+ positional_args=[ParsedPositionalArg(**p) for p in d.get("positional_args", [])],
88
+ subcommands=[_dict_to_subcommand(s) for s in d.get("subcommands", [])],
89
+ )
90
+
91
+
92
+ def _dict_to_subcommand(d: dict[str, Any]) -> ParsedSubcommand:
93
+ flags = d.get("flags")
94
+ positional = d.get("positional_args")
95
+ subs = d.get("subcommands")
96
+ return ParsedSubcommand(
97
+ name=d["name"],
98
+ aliases=d.get("aliases", []),
99
+ description=d.get("description", ""),
100
+ flags=[ParsedFlag(**f) for f in flags] if flags is not None else None,
101
+ positional_args=[ParsedPositionalArg(**p) for p in positional] if positional is not None else None,
102
+ subcommands=[_dict_to_subcommand(s) for s in subs] if subs else None,
103
+ )
104
+
105
+
106
+ def _dict_to_schema(d: dict[str, Any]) -> CliSchema:
107
+ if d.get("version") != CACHE_SCHEMA_VERSION:
108
+ raise ValueError(f"cache schema version mismatch: {d.get('version')}")
109
+ return CliSchema(
110
+ binary_name=d["binary_name"],
111
+ command=_dict_to_command(d["command"]),
112
+ )
113
+
114
+
115
+ # ------------------------------------------------------------------ public
116
+
117
+ def load_cached_schema(binary_name: str, *, subcommands: bool = True) -> CliSchema | None:
118
+ path = _cache_path(binary_name, subcommands=subcommands)
119
+ if not path.exists():
120
+ return None
121
+ try:
122
+ with path.open("r") as fh:
123
+ data = json.load(fh)
124
+ return _dict_to_schema(data)
125
+ except (OSError, json.JSONDecodeError, ValueError, KeyError, TypeError):
126
+ return None
127
+
128
+
129
+ def save_cached_schema(schema: CliSchema, *, subcommands: bool = True) -> Path:
130
+ path = _cache_path(schema.binary_name, subcommands=subcommands)
131
+ path.parent.mkdir(parents=True, exist_ok=True)
132
+ # Per-process tmp suffix avoids concurrent writers racing the same .tmp file.
133
+ tmp = path.with_suffix(f".{os.getpid()}.tmp")
134
+ try:
135
+ with tmp.open("w") as fh:
136
+ json.dump(_schema_to_dict(schema), fh, indent=2)
137
+ tmp.replace(path)
138
+ except Exception:
139
+ try:
140
+ tmp.unlink()
141
+ except OSError:
142
+ pass
143
+ raise
144
+ return path
145
+
146
+
147
+ def clear_cache(binary_name: str | None = None) -> int:
148
+ """Remove cached schemas. If binary_name is None, clear all. Returns count removed."""
149
+ removed = 0
150
+ base = cache_dir()
151
+ if not base.exists():
152
+ return 0
153
+ if binary_name is not None:
154
+ # Clear both subcommands=True and subcommands=False cache entries.
155
+ for scope in (True, False):
156
+ target = _cache_path(binary_name, subcommands=scope)
157
+ if target.exists():
158
+ target.unlink()
159
+ removed += 1
160
+ return removed
161
+ for entry in base.iterdir():
162
+ if entry.is_file() and entry.suffix == ".json":
163
+ entry.unlink()
164
+ removed += 1
165
+ return removed
cli_to_py/case.py ADDED
@@ -0,0 +1,11 @@
1
+ """Case conversion between Python snake_case and CLI --kebab-case."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def snake_to_kebab(name: str) -> str:
7
+ return name.replace("_", "-")
8
+
9
+
10
+ def kebab_to_snake(name: str) -> str:
11
+ return name.replace("-", "_")