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 +113 -0
- cli_to_py/_api_base.py +112 -0
- cli_to_py/api.py +159 -0
- cli_to_py/best_help.py +45 -0
- cli_to_py/cache.py +165 -0
- cli_to_py/case.py +11 -0
- cli_to_py/cli.py +99 -0
- cli_to_py/command_future.py +55 -0
- cli_to_py/command_string.py +19 -0
- cli_to_py/constants.py +12 -0
- cli_to_py/convert.py +102 -0
- cli_to_py/env_utils.py +46 -0
- cli_to_py/exec.py +432 -0
- cli_to_py/generate.py +306 -0
- cli_to_py/load_schema.py +45 -0
- cli_to_py/options_to_args.py +69 -0
- cli_to_py/parse_help.py +295 -0
- cli_to_py/parse_subcommands.py +186 -0
- cli_to_py/run_config.py +74 -0
- cli_to_py/schema.py +80 -0
- cli_to_py/script.py +114 -0
- cli_to_py/sync_api.py +136 -0
- cli_to_py/sync_exec.py +158 -0
- cli_to_py/validate.py +149 -0
- cli_to_py-0.1.0.dist-info/METADATA +135 -0
- cli_to_py-0.1.0.dist-info/RECORD +29 -0
- cli_to_py-0.1.0.dist-info/WHEEL +4 -0
- cli_to_py-0.1.0.dist-info/entry_points.txt +2 -0
- cli_to_py-0.1.0.dist-info/licenses/LICENSE +21 -0
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("-", "_")
|