fromargs 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.
- fromargs/__init__.py +22 -0
- fromargs/_app.py +292 -0
- fromargs/_argv.py +183 -0
- fromargs/_errors.py +16 -0
- fromargs/_output.py +49 -0
- fromargs/_run.py +180 -0
- fromargs/py.typed +0 -0
- fromargs-0.1.0.dist-info/METADATA +148 -0
- fromargs-0.1.0.dist-info/RECORD +11 -0
- fromargs-0.1.0.dist-info/WHEEL +4 -0
- fromargs-0.1.0.dist-info/licenses/LICENSE +21 -0
fromargs/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Self-healing Cyclopts CLI helpers for agent-friendly command lines.
|
|
2
|
+
|
|
3
|
+
``App`` composes a ``cyclopts.App``. Register a command with ``@app.command``
|
|
4
|
+
and a nested command group with ``app.group(name)``. A handler returns data,
|
|
5
|
+
not text: ``run`` parses argv once, invokes one handler, and prints the
|
|
6
|
+
return value as one JSON document on stdout. ``None`` means no stdout and
|
|
7
|
+
exit 0.
|
|
8
|
+
|
|
9
|
+
``run`` strips a bare ``--json`` or ``--full`` token from anywhere before the
|
|
10
|
+
end-of-options marker: ``--json`` is a no-op accepted for agents that pass it
|
|
11
|
+
by habit, and ``--full`` turns off result truncation. It also repairs one
|
|
12
|
+
verified shell-merged argument before it reports an error, and announces
|
|
13
|
+
each repair on stderr. Every error is one JSON line on stderr:
|
|
14
|
+
``{"error": <message>, "exit_code": <n>}``. There is no JSON input mode.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from cyclopts import Group, Parameter
|
|
18
|
+
|
|
19
|
+
from fromargs._app import App
|
|
20
|
+
from fromargs._errors import CliError, contract_error
|
|
21
|
+
|
|
22
|
+
__all__ = ["App", "CliError", "Group", "Parameter", "contract_error"]
|
fromargs/_app.py
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""``App``: a small wrapper that composes a Cyclopts app and forces JSON output.
|
|
2
|
+
|
|
3
|
+
Register a command with ``@app.command`` and a nested command group with
|
|
4
|
+
``app.group(name)``. A handler returns data, not text: ``run`` serializes
|
|
5
|
+
the return value as one JSON document. A handler must not declare a CLI
|
|
6
|
+
option named ``--json`` or ``--full``; those names are reserved for the
|
|
7
|
+
global flags that ``run`` owns.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import inspect
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable, Coroutine, Iterable, Sequence
|
|
15
|
+
from importlib import metadata
|
|
16
|
+
from typing import TYPE_CHECKING, Literal, TextIO, TypedDict, TypeVar, Unpack, overload
|
|
17
|
+
|
|
18
|
+
import cyclopts
|
|
19
|
+
|
|
20
|
+
from fromargs._argv import GLOBAL_FLAGS
|
|
21
|
+
from fromargs._run import run as _run
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from cyclopts.help.protocols import HelpFormatter
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T", bound=Callable[..., object])
|
|
28
|
+
|
|
29
|
+
_HelpFormat = Literal["markdown", "md", "plaintext", "restructuredtext", "rst", "rich"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _AppKwargs(TypedDict, total=False):
|
|
33
|
+
"""Keyword-only ``cyclopts.App`` constructor arguments this wrapper forwards untouched.
|
|
34
|
+
|
|
35
|
+
This mirrors the ``cyclopts.App`` constructor by hand; a future cyclopts
|
|
36
|
+
4.x release can add a keyword here before fromargs re-releases with it.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
usage: str | None
|
|
40
|
+
alias: str | Iterable[str] | None
|
|
41
|
+
synonym: str | Iterable[str] | None
|
|
42
|
+
default_command: Callable[..., object] | None
|
|
43
|
+
default_parameter: cyclopts.Parameter | None
|
|
44
|
+
config: (
|
|
45
|
+
Callable[[cyclopts.App, tuple[str, ...], cyclopts.ArgumentCollection], object]
|
|
46
|
+
| Iterable[Callable[[cyclopts.App, tuple[str, ...], cyclopts.ArgumentCollection], object]]
|
|
47
|
+
| None
|
|
48
|
+
)
|
|
49
|
+
version: str | Callable[..., str] | Callable[..., Coroutine[object, object, str]] | None
|
|
50
|
+
version_flags: str | Iterable[str] | None
|
|
51
|
+
show: bool
|
|
52
|
+
console: Console | None
|
|
53
|
+
error_console: Console | None
|
|
54
|
+
help_flags: str | Iterable[str] | None
|
|
55
|
+
help_format: _HelpFormat | None
|
|
56
|
+
help_on_error: bool | None
|
|
57
|
+
help_prologue: str | None
|
|
58
|
+
help_epilogue: str | None
|
|
59
|
+
version_format: _HelpFormat | None
|
|
60
|
+
group: cyclopts.Group | str | Iterable[cyclopts.Group | str] | None
|
|
61
|
+
group_arguments: str | cyclopts.Group | None
|
|
62
|
+
group_parameters: str | cyclopts.Group | None
|
|
63
|
+
group_commands: str | cyclopts.Group | None
|
|
64
|
+
validator: Callable[..., object] | Iterable[Callable[..., object]] | None
|
|
65
|
+
name_transform: Callable[[str], str] | None
|
|
66
|
+
sort_key: object
|
|
67
|
+
end_of_options_delimiter: str | None
|
|
68
|
+
print_error: bool | None
|
|
69
|
+
exit_on_error: bool | None
|
|
70
|
+
verbose: bool | None
|
|
71
|
+
suppress_keyboard_interrupt: bool
|
|
72
|
+
backend: Literal["asyncio", "trio"] | None
|
|
73
|
+
help_formatter: Literal["default", "plain"] | HelpFormatter | None
|
|
74
|
+
error_formatter: Callable[[cyclopts.CycloptsError], object] | None
|
|
75
|
+
result_action: cyclopts.ResultAction | None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class App:
|
|
79
|
+
"""Composes a ``cyclopts.App``; commands register through decorators and return data."""
|
|
80
|
+
|
|
81
|
+
def __init__(
|
|
82
|
+
self,
|
|
83
|
+
name: str | None = None,
|
|
84
|
+
*,
|
|
85
|
+
help: str | None = None,
|
|
86
|
+
**cyclopts_kwargs: Unpack[_AppKwargs],
|
|
87
|
+
) -> None:
|
|
88
|
+
if "version" not in cyclopts_kwargs:
|
|
89
|
+
frame = inspect.currentframe()
|
|
90
|
+
caller = frame.f_back if frame is not None else None
|
|
91
|
+
cyclopts_kwargs["version"] = _caller_version(caller.f_globals if caller is not None else {})
|
|
92
|
+
self._cyclopts: cyclopts.App = cyclopts.App(name=name, help=help, **cyclopts_kwargs)
|
|
93
|
+
self._limits: dict[int, int] = {}
|
|
94
|
+
if cyclopts_kwargs.get("default_command") is not None:
|
|
95
|
+
_reject_reserved(self._cyclopts)
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def _wrap(cls, cyclopts_app: cyclopts.App, limits: dict[int, int]) -> App:
|
|
99
|
+
"""Wrap an existing ``cyclopts.App`` without building a new one."""
|
|
100
|
+
wrapper = cls.__new__(cls)
|
|
101
|
+
wrapper._cyclopts = cyclopts_app
|
|
102
|
+
wrapper._limits = limits
|
|
103
|
+
return wrapper
|
|
104
|
+
|
|
105
|
+
@overload
|
|
106
|
+
def command(
|
|
107
|
+
self,
|
|
108
|
+
obj: T,
|
|
109
|
+
*,
|
|
110
|
+
name: str | Sequence[str] | None = None,
|
|
111
|
+
limit: int | None = None,
|
|
112
|
+
help: str | None = None,
|
|
113
|
+
**kwargs: Unpack[_AppKwargs],
|
|
114
|
+
) -> T: ...
|
|
115
|
+
|
|
116
|
+
@overload
|
|
117
|
+
def command(
|
|
118
|
+
self,
|
|
119
|
+
obj: None = None,
|
|
120
|
+
*,
|
|
121
|
+
name: str | Sequence[str] | None = None,
|
|
122
|
+
limit: int | None = None,
|
|
123
|
+
help: str | None = None,
|
|
124
|
+
**kwargs: Unpack[_AppKwargs],
|
|
125
|
+
) -> Callable[[T], T]: ...
|
|
126
|
+
|
|
127
|
+
def command(
|
|
128
|
+
self,
|
|
129
|
+
obj: T | None = None,
|
|
130
|
+
*,
|
|
131
|
+
name: str | Sequence[str] | None = None,
|
|
132
|
+
limit: int | None = None,
|
|
133
|
+
help: str | None = None,
|
|
134
|
+
**kwargs: Unpack[_AppKwargs],
|
|
135
|
+
) -> T | Callable[[T], T]:
|
|
136
|
+
"""Register ``obj`` as a command.
|
|
137
|
+
|
|
138
|
+
``limit`` truncates a sequence result to its first ``limit`` items
|
|
139
|
+
unless ``--full`` is passed; it must not be negative. ``obj`` must
|
|
140
|
+
not declare a CLI option named ``--json`` or ``--full``.
|
|
141
|
+
"""
|
|
142
|
+
if obj is None:
|
|
143
|
+
|
|
144
|
+
def register(handler: T) -> T:
|
|
145
|
+
return self.command(handler, name=name, limit=limit, help=help, **kwargs)
|
|
146
|
+
|
|
147
|
+
return register
|
|
148
|
+
_check_limit(limit)
|
|
149
|
+
before = set(self._cyclopts)
|
|
150
|
+
_ = self._cyclopts.command(obj, name=name, help=help, **kwargs)
|
|
151
|
+
registered = sorted(set(self._cyclopts) - before)
|
|
152
|
+
sub_app = self._cyclopts[registered[0]]
|
|
153
|
+
try:
|
|
154
|
+
_reject_reserved(sub_app)
|
|
155
|
+
except ValueError:
|
|
156
|
+
for key in registered:
|
|
157
|
+
del self._cyclopts[key]
|
|
158
|
+
raise
|
|
159
|
+
if limit is not None:
|
|
160
|
+
self._limits[id(sub_app)] = limit
|
|
161
|
+
return obj
|
|
162
|
+
|
|
163
|
+
@overload
|
|
164
|
+
def default(
|
|
165
|
+
self,
|
|
166
|
+
obj: T,
|
|
167
|
+
*,
|
|
168
|
+
limit: int | None = None,
|
|
169
|
+
validator: Callable[..., object] | None = None,
|
|
170
|
+
) -> T: ...
|
|
171
|
+
|
|
172
|
+
@overload
|
|
173
|
+
def default(
|
|
174
|
+
self,
|
|
175
|
+
obj: None = None,
|
|
176
|
+
*,
|
|
177
|
+
limit: int | None = None,
|
|
178
|
+
validator: Callable[..., object] | None = None,
|
|
179
|
+
) -> Callable[[T], T]: ...
|
|
180
|
+
|
|
181
|
+
def default(
|
|
182
|
+
self,
|
|
183
|
+
obj: T | None = None,
|
|
184
|
+
*,
|
|
185
|
+
limit: int | None = None,
|
|
186
|
+
validator: Callable[..., object] | None = None,
|
|
187
|
+
) -> T | Callable[[T], T]:
|
|
188
|
+
"""Register ``obj`` as the handler that runs when argv names no subcommand.
|
|
189
|
+
|
|
190
|
+
``limit`` truncates a sequence result to its first ``limit`` items
|
|
191
|
+
unless ``--full`` is passed; it must not be negative. ``obj`` must
|
|
192
|
+
not declare a CLI option named ``--json`` or ``--full``. Raises
|
|
193
|
+
``ValueError`` when a default handler is already registered.
|
|
194
|
+
"""
|
|
195
|
+
if obj is None:
|
|
196
|
+
|
|
197
|
+
def register(handler: T) -> T:
|
|
198
|
+
return self.default(handler, limit=limit, validator=validator)
|
|
199
|
+
|
|
200
|
+
return register
|
|
201
|
+
_check_limit(limit)
|
|
202
|
+
previous = self._cyclopts.default_command
|
|
203
|
+
previous_validator = self._cyclopts.validator
|
|
204
|
+
try:
|
|
205
|
+
_ = self._cyclopts.default(obj, validator=validator)
|
|
206
|
+
except cyclopts.CommandCollisionError as exc:
|
|
207
|
+
raise ValueError(str(exc)) from exc
|
|
208
|
+
try:
|
|
209
|
+
_reject_reserved(self._cyclopts)
|
|
210
|
+
except ValueError:
|
|
211
|
+
self._cyclopts.default_command = previous
|
|
212
|
+
self._cyclopts.validator = previous_validator
|
|
213
|
+
raise
|
|
214
|
+
if limit is not None:
|
|
215
|
+
self._limits[id(self._cyclopts)] = limit
|
|
216
|
+
return obj
|
|
217
|
+
|
|
218
|
+
def group(self, name: str, *, help: str | None = None, **cyclopts_kwargs: Unpack[_AppKwargs]) -> App:
|
|
219
|
+
"""Return a nested command group registered under this app."""
|
|
220
|
+
sub = cyclopts.App(name=name, help=help, **cyclopts_kwargs)
|
|
221
|
+
if cyclopts_kwargs.get("default_command") is not None:
|
|
222
|
+
_reject_reserved(sub)
|
|
223
|
+
_ = self._cyclopts.command(sub)
|
|
224
|
+
return App._wrap(sub, self._limits)
|
|
225
|
+
|
|
226
|
+
def run(self, argv: Sequence[str] | None = None, *, stdout: TextIO | None = None) -> int:
|
|
227
|
+
"""Parse argv once, invoke one handler, and return its exit status."""
|
|
228
|
+
return _run(self._cyclopts, argv=argv, stdout=stdout, limits=self._limits)
|
|
229
|
+
|
|
230
|
+
def main(self) -> None:
|
|
231
|
+
"""Run with ``sys.argv`` and exit the process with the returned status."""
|
|
232
|
+
sys.exit(self.run())
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _reserved_option(app: cyclopts.App) -> str | None:
|
|
236
|
+
"""The first reserved global flag name ``app``'s assembled arguments claim, or ``None``."""
|
|
237
|
+
try:
|
|
238
|
+
arguments = app.assemble_argument_collection()
|
|
239
|
+
except ValueError:
|
|
240
|
+
return None
|
|
241
|
+
names: set[str] = set()
|
|
242
|
+
for argument in arguments:
|
|
243
|
+
names.update(argument.names)
|
|
244
|
+
reserved = sorted(GLOBAL_FLAGS.intersection(names))
|
|
245
|
+
return reserved[0] if reserved else None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _reject_reserved(app: cyclopts.App) -> None:
|
|
249
|
+
"""Raise ``ValueError`` when ``app``'s assembled arguments claim a reserved global flag."""
|
|
250
|
+
reserved = _reserved_option(app)
|
|
251
|
+
if reserved is not None:
|
|
252
|
+
raise ValueError(f"command option {reserved!r} is reserved by fromargs")
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _check_limit(limit: int | None) -> None:
|
|
256
|
+
"""Raise ``ValueError`` when ``limit`` is negative."""
|
|
257
|
+
if limit is not None and limit < 0:
|
|
258
|
+
raise ValueError(f"limit must not be negative, got {limit}")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _caller_version(module_globals: dict[str, object]) -> Callable[[], str]:
|
|
262
|
+
"""Resolve ``--version`` for the module that built the ``App``, not for ``fromargs``.
|
|
263
|
+
|
|
264
|
+
Cyclopts reads the version of the module that constructs ``cyclopts.App``;
|
|
265
|
+
here that is always ``fromargs._app``. This looks up the caller instead:
|
|
266
|
+
its distribution version (by import name, then by the one distribution
|
|
267
|
+
that provides that import name), then its ``__version__``, then ``0.0.0``.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
def resolve() -> str:
|
|
271
|
+
caller_name = str(module_globals.get("__name__", ""))
|
|
272
|
+
spec_name = getattr(module_globals.get("__spec__"), "name", None)
|
|
273
|
+
module_name = (
|
|
274
|
+
spec_name
|
|
275
|
+
if caller_name == "__main__"
|
|
276
|
+
and isinstance(spec_name, str)
|
|
277
|
+
and spec_name.endswith(".__main__")
|
|
278
|
+
else caller_name
|
|
279
|
+
)
|
|
280
|
+
root = module_name.split(".")[0]
|
|
281
|
+
candidates = [root]
|
|
282
|
+
providers = metadata.packages_distributions().get(root, [])
|
|
283
|
+
if len(providers) == 1:
|
|
284
|
+
candidates.append(providers[0])
|
|
285
|
+
for candidate in candidates:
|
|
286
|
+
try:
|
|
287
|
+
return metadata.version(candidate)
|
|
288
|
+
except (metadata.PackageNotFoundError, ValueError):
|
|
289
|
+
continue
|
|
290
|
+
return str(module_globals.get("__version__", "0.0.0"))
|
|
291
|
+
|
|
292
|
+
return resolve
|
fromargs/_argv.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Argv repairs and scans that run around the single Cyclopts parse.
|
|
2
|
+
|
|
3
|
+
Quote repair uses only a verified split. The global ``--json``/``--full``
|
|
4
|
+
flags are stripped from anywhere before the end-of-options marker; they are
|
|
5
|
+
never hoisted and never passed to a handler. Only a verified quote split
|
|
6
|
+
prints a plain-text ``note:`` line on stderr. Probing only parses; handlers
|
|
7
|
+
never run here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import shlex
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import Callable, Sequence
|
|
15
|
+
from inspect import BoundArguments
|
|
16
|
+
from typing import cast, get_args, get_origin
|
|
17
|
+
|
|
18
|
+
from cyclopts import App, CycloptsError
|
|
19
|
+
from cyclopts.annotations import is_union
|
|
20
|
+
|
|
21
|
+
from fromargs._errors import CliError
|
|
22
|
+
|
|
23
|
+
GLOBAL_FLAGS = frozenset({"--json", "--full"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def strip_global_flags(app: App, argv: Sequence[str]) -> tuple[list[str], bool]:
|
|
27
|
+
"""Remove bare ``--json``/``--full`` tokens before the end-of-options marker.
|
|
28
|
+
|
|
29
|
+
``--json`` is a no-op; ``--full`` turns off truncation. Neither flag ever
|
|
30
|
+
reaches a handler or Cyclopts. Returns the stripped tokens and whether
|
|
31
|
+
``--full`` was present.
|
|
32
|
+
"""
|
|
33
|
+
tokens = list(argv)
|
|
34
|
+
boundary = len(_options(app, tokens))
|
|
35
|
+
before, after = tokens[:boundary], tokens[boundary:]
|
|
36
|
+
kept = [token for token in before if token not in GLOBAL_FLAGS]
|
|
37
|
+
return kept + after, "--full" in before
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def repair_rejected(app: App, argv: Sequence[str]) -> list[str] | None:
|
|
41
|
+
"""Return the one verified split of an argv the app rejected, or ``None``."""
|
|
42
|
+
original = list(argv)
|
|
43
|
+
control_flags = _control_flags(app, original)
|
|
44
|
+
if control_flags.intersection(original):
|
|
45
|
+
return None
|
|
46
|
+
splittable = _splittable_options(app, original)
|
|
47
|
+
if splittable is None:
|
|
48
|
+
return None
|
|
49
|
+
found: tuple[list[str], str, list[str]] | None = None
|
|
50
|
+
for index, token in enumerate(_options(app, original)):
|
|
51
|
+
pieces = _pieces(token)
|
|
52
|
+
if pieces is None or control_flags.intersection(pieces):
|
|
53
|
+
continue
|
|
54
|
+
previous = original[index - 1] if index else ""
|
|
55
|
+
option = token.partition("=")[0] if token.startswith("--") else previous
|
|
56
|
+
if option not in splittable:
|
|
57
|
+
continue
|
|
58
|
+
candidate = original[:index] + pieces + original[index + 1 :]
|
|
59
|
+
probe, _ = strip_global_flags(app, candidate)
|
|
60
|
+
if not _parses(app, probe):
|
|
61
|
+
continue
|
|
62
|
+
if found is not None:
|
|
63
|
+
return None
|
|
64
|
+
found = (candidate, token, pieces)
|
|
65
|
+
if found is None:
|
|
66
|
+
return None
|
|
67
|
+
candidate, token, pieces = found
|
|
68
|
+
print(f"note: split quoted argument {token!r} into {pieces!r}", file=sys.stderr)
|
|
69
|
+
return candidate
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def command_chain(app: App, argv: Sequence[str]) -> tuple[App, ...] | None:
|
|
73
|
+
"""The resolved chain of command apps for ``argv``, or ``None`` when parsing fails."""
|
|
74
|
+
try:
|
|
75
|
+
_, apps, _ = app.parse_commands(list(argv))
|
|
76
|
+
except (CycloptsError, ValueError):
|
|
77
|
+
return None
|
|
78
|
+
return apps
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parse_once(app: App, argv: Sequence[str]) -> tuple[Callable[..., object], BoundArguments]:
|
|
82
|
+
"""Parse ``argv`` once; a converter's ``ValueError`` is reported as a ``CycloptsError``."""
|
|
83
|
+
try:
|
|
84
|
+
handler, bound, _ = app.parse_args(
|
|
85
|
+
list(argv), print_error=False, exit_on_error=False, help_on_error=False
|
|
86
|
+
)
|
|
87
|
+
except ValueError as exc:
|
|
88
|
+
raise CycloptsError(msg=str(exc)) from exc
|
|
89
|
+
return handler, bound
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _parses(app: App, argv: Sequence[str]) -> bool:
|
|
93
|
+
"""Probe-parse ``argv``; a converter's ``CliError`` counts as a rejection."""
|
|
94
|
+
try:
|
|
95
|
+
_ = parse_once(app, argv)
|
|
96
|
+
except (CycloptsError, CliError):
|
|
97
|
+
return False
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _control_flags(app: App, argv: Sequence[str]) -> frozenset[str]:
|
|
102
|
+
"""Help and version flags from every app in the parsed command chain."""
|
|
103
|
+
apps = command_chain(app, argv) or (app,)
|
|
104
|
+
flags: set[str] = set()
|
|
105
|
+
for command_app in apps:
|
|
106
|
+
flags.update(command_app.help_flags)
|
|
107
|
+
flags.update(command_app.version_flags)
|
|
108
|
+
return frozenset(flags)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _options(app: App, argv: Sequence[str]) -> list[str]:
|
|
112
|
+
"""The tokens before the command's end-of-options marker (``--`` by default).
|
|
113
|
+
|
|
114
|
+
The innermost command app with a configured marker decides, as in Cyclopts.
|
|
115
|
+
"""
|
|
116
|
+
tokens = list(argv)
|
|
117
|
+
apps = command_chain(app, tokens) or (app,)
|
|
118
|
+
configured = [
|
|
119
|
+
command_app.end_of_options_delimiter
|
|
120
|
+
for command_app in apps
|
|
121
|
+
if command_app.end_of_options_delimiter is not None
|
|
122
|
+
]
|
|
123
|
+
delimiter = configured[-1] if configured else "--"
|
|
124
|
+
if delimiter and delimiter in tokens:
|
|
125
|
+
return tokens[: tokens.index(delimiter)]
|
|
126
|
+
return tokens
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _splittable_options(app: App, argv: Sequence[str]) -> set[str] | None:
|
|
130
|
+
"""Names of the options that can take a split value: no flags, no free-text ``str``."""
|
|
131
|
+
apps = command_chain(app, argv)
|
|
132
|
+
if apps is None:
|
|
133
|
+
return None
|
|
134
|
+
options: set[str] = set()
|
|
135
|
+
for command_app in apps:
|
|
136
|
+
try:
|
|
137
|
+
arguments = command_app.assemble_argument_collection()
|
|
138
|
+
except ValueError:
|
|
139
|
+
continue
|
|
140
|
+
for argument in arguments:
|
|
141
|
+
if argument.is_flag():
|
|
142
|
+
continue
|
|
143
|
+
if _is_free_text(cast(object, argument.hint)) and argument.get_choices() is None:
|
|
144
|
+
continue
|
|
145
|
+
options.update(argument.names)
|
|
146
|
+
return options
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_free_text(hint: object) -> bool:
|
|
150
|
+
"""True when ``hint`` is unstructured text: ``str``, a path-like, or a sequence of them."""
|
|
151
|
+
if isinstance(hint, type):
|
|
152
|
+
cls = cast("type[object]", hint)
|
|
153
|
+
if issubclass(cls, str) or hasattr(cls, "__fspath__"):
|
|
154
|
+
return True
|
|
155
|
+
if is_union(hint): # pyright: ignore[reportArgumentType] # cyclopts types this as type | None but accepts UnionType/Annotated hints at runtime
|
|
156
|
+
return all(
|
|
157
|
+
argument is type(None) or _is_free_text(argument)
|
|
158
|
+
for argument in cast("tuple[object, ...]", get_args(hint))
|
|
159
|
+
)
|
|
160
|
+
origin = get_origin(hint)
|
|
161
|
+
if origin in (list, tuple, set, frozenset, Sequence):
|
|
162
|
+
args = tuple(
|
|
163
|
+
argument
|
|
164
|
+
for argument in cast("tuple[object, ...]", get_args(hint))
|
|
165
|
+
if argument is not Ellipsis
|
|
166
|
+
)
|
|
167
|
+
return bool(args) and all(_is_free_text(argument) for argument in args)
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _pieces(token: str) -> list[str] | None:
|
|
172
|
+
"""Shell-split ``token`` when it looks like several merged arguments."""
|
|
173
|
+
if not any(character.isspace() for character in token):
|
|
174
|
+
return None
|
|
175
|
+
try:
|
|
176
|
+
pieces = shlex.split(token)
|
|
177
|
+
except ValueError:
|
|
178
|
+
return None
|
|
179
|
+
if len(pieces) < 2 or not any(
|
|
180
|
+
piece.startswith("-") and len(piece) > 1 for piece in pieces
|
|
181
|
+
):
|
|
182
|
+
return None
|
|
183
|
+
return pieces
|
fromargs/_errors.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""The error type that ``run`` reports as one stderr line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CliError(Exception):
|
|
7
|
+
"""One-line error; ``run`` reports it on stderr and returns ``exit_code``."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, *, exit_code: int = 2) -> None:
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.exit_code: int = exit_code
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def contract_error(exc: Exception, *, context: str) -> CliError:
|
|
15
|
+
"""Wrap a contract-violation exception as a ``CliError`` that exits 3."""
|
|
16
|
+
return CliError(f"{context}: {exc}", exit_code=3)
|
fromargs/_output.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Serialize a command's return value as one JSON document, with truncation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import itertools
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from typing import TextIO, cast
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def write_result(
|
|
15
|
+
value: object, *, limit: int | None, full: bool, stdout: TextIO | None = None
|
|
16
|
+
) -> None:
|
|
17
|
+
"""Print ``value`` as one JSON document; truncate a long sequence unless ``full``.
|
|
18
|
+
|
|
19
|
+
A sequence longer than ``limit`` is cut to its first ``limit`` items and
|
|
20
|
+
stderr gets a ``note:`` line, unless ``full`` is true or ``limit`` is
|
|
21
|
+
``None``. Truncation never applies to a mapping or a string. ``NaN``
|
|
22
|
+
and ``Infinity`` are rejected, and a type ``_default`` cannot resolve
|
|
23
|
+
raises ``TypeError`` instead of printing a misleading fallback.
|
|
24
|
+
"""
|
|
25
|
+
stream = stdout if stdout is not None else sys.stdout
|
|
26
|
+
payload = value
|
|
27
|
+
note: str | None = None
|
|
28
|
+
if limit is not None and isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
29
|
+
total = len(value)
|
|
30
|
+
if not full and total > limit:
|
|
31
|
+
payload = list(itertools.islice(value, limit))
|
|
32
|
+
note = f"note: showing {limit} of {total}; pass --full for the rest"
|
|
33
|
+
serialized = json.dumps(payload, indent=2, default=_default, allow_nan=False)
|
|
34
|
+
if note is not None:
|
|
35
|
+
print(note, file=sys.stderr)
|
|
36
|
+
print(serialized, file=stream)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _default(value: object) -> object:
|
|
40
|
+
"""Fallback for ``json.dumps``: dataclass via ``asdict``, path via ``str``, else mapping, else list."""
|
|
41
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
42
|
+
return dataclasses.asdict(value)
|
|
43
|
+
if isinstance(value, os.PathLike):
|
|
44
|
+
return str(value)
|
|
45
|
+
if isinstance(value, Mapping):
|
|
46
|
+
return dict(cast("Mapping[object, object]", value))
|
|
47
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
|
48
|
+
return list(value)
|
|
49
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
fromargs/_run.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Parse argv once, invoke one handler, and print its result as one JSON document."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import traceback
|
|
13
|
+
from collections.abc import Callable, Coroutine, Sequence
|
|
14
|
+
from contextlib import nullcontext, redirect_stdout
|
|
15
|
+
from inspect import BoundArguments
|
|
16
|
+
from typing import TextIO
|
|
17
|
+
|
|
18
|
+
from cyclopts import App, CycloptsError
|
|
19
|
+
|
|
20
|
+
from fromargs._argv import command_chain, parse_once, repair_rejected, strip_global_flags
|
|
21
|
+
from fromargs._errors import CliError
|
|
22
|
+
from fromargs._output import write_result
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _AsyncContractError(TypeError):
|
|
26
|
+
"""A programmer-contract violation for an async handler; always re-raised."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run(
|
|
30
|
+
app: App,
|
|
31
|
+
*,
|
|
32
|
+
argv: Sequence[str] | None = None,
|
|
33
|
+
stdout: TextIO | None = None,
|
|
34
|
+
limits: dict[int, int] | None = None,
|
|
35
|
+
) -> int:
|
|
36
|
+
"""Parse argv, invoke the command once, and return its exit status.
|
|
37
|
+
|
|
38
|
+
A bare ``--json`` or ``--full`` token anywhere before the end-of-options
|
|
39
|
+
marker is stripped before parsing. ``--json`` is a no-op; ``--full``
|
|
40
|
+
turns off result truncation. A rejected argv gets one verified
|
|
41
|
+
quote-split repair before it fails. A command chain that resolves to a
|
|
42
|
+
bare help print without an explicit help flag is reported as a missing
|
|
43
|
+
command instead of printing help.
|
|
44
|
+
|
|
45
|
+
``None`` from the handler means exit 0 with no stdout. Any other return
|
|
46
|
+
value is printed as one JSON document and the command exits 0. A
|
|
47
|
+
coroutine handler runs through ``asyncio.run``. ``TypeError`` is raised
|
|
48
|
+
for a ``str`` argv, and for an async handler under a running event loop
|
|
49
|
+
or a non-asyncio backend on the resolved command chain.
|
|
50
|
+
|
|
51
|
+
Every ADR-001 error (a ``CliError`` or a rejected parse) is one JSON
|
|
52
|
+
line on stderr: ``{"error": <message>, "exit_code": <n>}``. An
|
|
53
|
+
unexpected exception from the handler or from result serialization
|
|
54
|
+
(including a ``CycloptsError`` raised by the handler) instead gets a
|
|
55
|
+
three-key envelope with a ``traceback`` path to a file holding the full
|
|
56
|
+
traceback, and returns 1. Repair ``note:`` lines stay plain text on
|
|
57
|
+
stderr.
|
|
58
|
+
"""
|
|
59
|
+
if isinstance(argv, str):
|
|
60
|
+
raise TypeError("argv must be a sequence of strings, not str")
|
|
61
|
+
tokens = list(sys.argv[1:] if argv is None else argv)
|
|
62
|
+
tokens, full = strip_global_flags(app, tokens)
|
|
63
|
+
context = redirect_stdout(stdout) if stdout is not None else nullcontext()
|
|
64
|
+
with context:
|
|
65
|
+
try:
|
|
66
|
+
handler, bound, tokens, full, apps = _parse(app, tokens, full)
|
|
67
|
+
except CliError as exc:
|
|
68
|
+
return _report(str(exc), exc.exit_code)
|
|
69
|
+
except CycloptsError as exc:
|
|
70
|
+
return _report(_safe_message(exc), 2)
|
|
71
|
+
if _is_bare_help(handler) and not _requested_help(apps, tokens):
|
|
72
|
+
return _report("command required", 2)
|
|
73
|
+
try:
|
|
74
|
+
status = handler(*bound.args, **bound.kwargs)
|
|
75
|
+
if inspect.iscoroutine(status):
|
|
76
|
+
status = _await(apps, status)
|
|
77
|
+
except CliError as exc:
|
|
78
|
+
return _report(str(exc), exc.exit_code)
|
|
79
|
+
except _AsyncContractError:
|
|
80
|
+
raise
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
return _report_unexpected(exc)
|
|
83
|
+
if status is None:
|
|
84
|
+
return 0
|
|
85
|
+
limit = None if limits is None else limits.get(id(apps[-1]))
|
|
86
|
+
try:
|
|
87
|
+
write_result(status, limit=limit, full=full, stdout=stdout)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
return _report_unexpected(exc)
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse(
|
|
94
|
+
app: App, tokens: list[str], full: bool
|
|
95
|
+
) -> tuple[Callable[..., object], BoundArguments, list[str], bool, tuple[App, ...]]:
|
|
96
|
+
"""Parse ``tokens``; on rejection, parse only a verified repair."""
|
|
97
|
+
try:
|
|
98
|
+
handler, bound = parse_once(app, tokens)
|
|
99
|
+
except (CycloptsError, CliError):
|
|
100
|
+
repaired = repair_rejected(app, tokens)
|
|
101
|
+
if repaired is None:
|
|
102
|
+
raise
|
|
103
|
+
tokens, more_full = strip_global_flags(app, repaired)
|
|
104
|
+
handler, bound = parse_once(app, tokens)
|
|
105
|
+
full = full or more_full
|
|
106
|
+
apps = command_chain(app, tokens) or (app,)
|
|
107
|
+
return handler, bound, tokens, full, apps
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_bare_help(handler: Callable[..., object]) -> bool:
|
|
111
|
+
"""True when ``handler`` is a command app's unparametrized ``help_print``."""
|
|
112
|
+
return inspect.ismethod(handler) and handler.__func__ is App.help_print
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _requested_help(apps: tuple[App, ...], tokens: Sequence[str]) -> bool:
|
|
116
|
+
"""True when ``tokens`` literally contains a help flag from the command chain."""
|
|
117
|
+
flags: set[str] = set()
|
|
118
|
+
for command_app in apps:
|
|
119
|
+
flags.update(command_app.help_flags)
|
|
120
|
+
return bool(flags.intersection(tokens))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _await(apps: tuple[App, ...], coroutine: Coroutine[object, object, object]) -> object:
|
|
124
|
+
"""Run an async handler only when its effective backend is asyncio."""
|
|
125
|
+
backend = next(
|
|
126
|
+
(command_app.backend for command_app in reversed(apps) if command_app.backend is not None),
|
|
127
|
+
"asyncio",
|
|
128
|
+
)
|
|
129
|
+
if backend != "asyncio":
|
|
130
|
+
coroutine.close()
|
|
131
|
+
raise _AsyncContractError(f"async commands need the asyncio backend, not {backend!r}")
|
|
132
|
+
try:
|
|
133
|
+
_ = asyncio.get_running_loop()
|
|
134
|
+
except RuntimeError:
|
|
135
|
+
return asyncio.run(coroutine)
|
|
136
|
+
coroutine.close()
|
|
137
|
+
raise _AsyncContractError("async commands cannot run inside a running event loop")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _report(message: str, exit_code: int) -> int:
|
|
141
|
+
print(json.dumps({"error": message, "exit_code": exit_code}), file=sys.stderr)
|
|
142
|
+
return exit_code
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _safe_message(exc: CycloptsError) -> str:
|
|
146
|
+
"""Render ``exc`` for the ADR-001 envelope, even when ``str(exc)`` itself raises.
|
|
147
|
+
|
|
148
|
+
Cyclopts' ``ValidationError.__str__`` raises ``NotImplementedError`` when
|
|
149
|
+
none of ``argument``, ``group``, or ``command_chain`` is set, which
|
|
150
|
+
happens for a root default handler's validator.
|
|
151
|
+
"""
|
|
152
|
+
try:
|
|
153
|
+
return str(exc)
|
|
154
|
+
except Exception:
|
|
155
|
+
return getattr(exc, "exception_message", "") or type(exc).__name__
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _report_unexpected(exc: Exception) -> int:
|
|
159
|
+
"""Report an unexpected exception as a two- or three-key envelope; return 1.
|
|
160
|
+
|
|
161
|
+
The envelope gets a ``traceback`` path when the traceback file writes
|
|
162
|
+
successfully. An ``OSError`` while creating or writing that file omits
|
|
163
|
+
the ``traceback`` key instead of escaping the ADR-001 envelope.
|
|
164
|
+
"""
|
|
165
|
+
envelope: dict[str, object] = {"error": f"{type(exc).__name__}: {exc}", "exit_code": 1}
|
|
166
|
+
try:
|
|
167
|
+
descriptor, path = tempfile.mkstemp(prefix="fromargs-", suffix=".traceback")
|
|
168
|
+
except OSError:
|
|
169
|
+
pass
|
|
170
|
+
else:
|
|
171
|
+
try:
|
|
172
|
+
with open(descriptor, "w") as handle:
|
|
173
|
+
_ = handle.write(traceback.format_exc())
|
|
174
|
+
except OSError:
|
|
175
|
+
with contextlib.suppress(OSError):
|
|
176
|
+
os.unlink(path)
|
|
177
|
+
else:
|
|
178
|
+
envelope["traceback"] = path
|
|
179
|
+
print(json.dumps(envelope), file=sys.stderr)
|
|
180
|
+
return 1
|
fromargs/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: fromargs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Self-healing Cyclopts CLI helpers for agent-friendly command lines.
|
|
5
|
+
Project-URL: Homepage, https://github.com/paulnsorensen/skillz-that-grillz/tree/main/lib/fromargs
|
|
6
|
+
Project-URL: Source, https://github.com/paulnsorensen/skillz-that-grillz/tree/main/lib/fromargs
|
|
7
|
+
Project-URL: Issues, https://github.com/paulnsorensen/skillz-that-grillz/issues
|
|
8
|
+
Author-email: Paul Sorensen <paulnsorensen@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agent,cli,cyclopts,json
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: cyclopts<5,>=4.25.3
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# fromargs
|
|
24
|
+
|
|
25
|
+
`fromargs` is a self-healing, agent-friendly wrapper around
|
|
26
|
+
[Cyclopts](https://cyclopts.readthedocs.io/). It composes one `cyclopts.App`,
|
|
27
|
+
forces every command's return value to JSON, and repairs the argv mistakes an
|
|
28
|
+
LLM agent tends to make, without ever guessing at intent it cannot verify.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
uv add fromargs
|
|
34
|
+
# or
|
|
35
|
+
pip install fromargs
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`fromargs` pins `cyclopts>=4.25.3,<5`; it does not yet track Cyclopts 5.
|
|
39
|
+
|
|
40
|
+
## Quick start
|
|
41
|
+
|
|
42
|
+
```python
|
|
43
|
+
from typing import Annotated
|
|
44
|
+
|
|
45
|
+
import fromargs
|
|
46
|
+
|
|
47
|
+
app = fromargs.App("cheese-cave", help="Track wheels of cheese as they ripen.")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command
|
|
51
|
+
def age(
|
|
52
|
+
name: str,
|
|
53
|
+
*,
|
|
54
|
+
weeks: Annotated[int, fromargs.Parameter(help="Number of weeks to age.")],
|
|
55
|
+
dry_run: bool = False,
|
|
56
|
+
) -> dict[str, object]:
|
|
57
|
+
"""Age one wheel for more weeks."""
|
|
58
|
+
if weeks < 1:
|
|
59
|
+
raise fromargs.CliError(f"--weeks must be at least 1, got {weeks}")
|
|
60
|
+
return {"name": name, "weeks": weeks, "dry_run": dry_run}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
if __name__ == "__main__":
|
|
64
|
+
app.main()
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
$ python cheese_cave.py age brie --weeks 2
|
|
69
|
+
{
|
|
70
|
+
"name": "brie",
|
|
71
|
+
"weeks": 2,
|
|
72
|
+
"dry_run": false
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
See `examples/cheese_cave.py` for a fuller example, with a command group and
|
|
77
|
+
a truncated list result.
|
|
78
|
+
|
|
79
|
+
## Output contract
|
|
80
|
+
|
|
81
|
+
- A handler returns data, not text. A non-`None` return value prints as one
|
|
82
|
+
JSON document on stdout, then the process exits `0`.
|
|
83
|
+
- A `None` return value means exit `0` with no stdout.
|
|
84
|
+
- Every error is one JSON line on stderr: `{"error": <message>, "exit_code": <n>}`.
|
|
85
|
+
Raise `fromargs.CliError(message)` for exit code `2`, or
|
|
86
|
+
`fromargs.contract_error(exc, context=...)` to wrap a caught exception at
|
|
87
|
+
exit code `3`. An unhandled Cyclopts parse error also reports at exit
|
|
88
|
+
code `2`.
|
|
89
|
+
- A quote-split repair (below) prints one plain-text `note:` line on stderr;
|
|
90
|
+
it never changes stdout or the exit code.
|
|
91
|
+
|
|
92
|
+
## Global flags
|
|
93
|
+
|
|
94
|
+
`fromargs` strips two flags from argv before Cyclopts ever sees them, from
|
|
95
|
+
anywhere before the end-of-options marker:
|
|
96
|
+
|
|
97
|
+
- `--json` is a no-op. Agents that append it by habit get plain JSON either
|
|
98
|
+
way, so the flag costs nothing and fails nothing.
|
|
99
|
+
- `--full` turns off result truncation for the current call.
|
|
100
|
+
|
|
101
|
+
Neither flag reaches a handler, and neither is a real Cyclopts option.
|
|
102
|
+
|
|
103
|
+
## `limit`
|
|
104
|
+
|
|
105
|
+
`@app.command(limit=n)` truncates a sequence result to its first `n` items,
|
|
106
|
+
unless the caller passes `--full`. Truncation prints a `note:` line on
|
|
107
|
+
stderr and never applies to a mapping or a string. `App.default` accepts the
|
|
108
|
+
same `limit` keyword.
|
|
109
|
+
|
|
110
|
+
## `App.default`
|
|
111
|
+
|
|
112
|
+
`@app.default` (bare or called, matching `@app.command`) registers the
|
|
113
|
+
handler that runs when argv names no subcommand at that app or group level.
|
|
114
|
+
It is rejected at registration if it declares a `json` or `full` parameter,
|
|
115
|
+
the same rule `@app.command` enforces. Registering a second default on the
|
|
116
|
+
same app or group raises `ValueError`.
|
|
117
|
+
|
|
118
|
+
## Self-healing
|
|
119
|
+
|
|
120
|
+
An agent's shell layer sometimes merges two arguments into one quoted token,
|
|
121
|
+
for example `--weeks "2 --dry-run"` instead of `--weeks 2 --dry-run`. When
|
|
122
|
+
Cyclopts rejects an argv, `fromargs` shell-splits each option's value once
|
|
123
|
+
and re-parses. It applies a split only when:
|
|
124
|
+
|
|
125
|
+
- the split has at least two pieces, and one looks like a flag; and
|
|
126
|
+
- the option can take a split value (not a boolean flag, not free-text `str`
|
|
127
|
+
or `Path`); and
|
|
128
|
+
- exactly one split candidate among all options parses cleanly.
|
|
129
|
+
|
|
130
|
+
It prints `note: split quoted argument ... into ...` on stderr when it
|
|
131
|
+
applies a repair. `fromargs` refuses to guess when a split is ambiguous
|
|
132
|
+
(more than one candidate parses), when the option takes free text, or for
|
|
133
|
+
any token after the end-of-options marker (`--` by default). In every
|
|
134
|
+
refusal case, the original parse error is reported unchanged.
|
|
135
|
+
|
|
136
|
+
## Version resolution
|
|
137
|
+
|
|
138
|
+
`fromargs.App(name)` reports the version of the *calling* module, not the
|
|
139
|
+
version of `fromargs` itself. It resolves, in order:
|
|
140
|
+
|
|
141
|
+
1. an explicit `version=` argument, if the caller passes one;
|
|
142
|
+
2. `importlib.metadata.version(...)` for the caller's installed distribution;
|
|
143
|
+
3. the caller module's `__version__` attribute;
|
|
144
|
+
4. `"0.0.0"`, if none of the above resolve.
|
|
145
|
+
|
|
146
|
+
`App.group(name, version=..., **cyclopts_kwargs)` forwards every extra
|
|
147
|
+
keyword, including `version`, to the nested `cyclopts.App`, so a group can
|
|
148
|
+
report its own version independently of the root app.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
fromargs/__init__.py,sha256=-XihBeeoNIxZsQcqsavW9ujRT79e0VAJdRGrn4H-E5M,1019
|
|
2
|
+
fromargs/_app.py,sha256=mH9emBozkApJI59mqapb4R7f7yAjCTorbx0i5DH3Syo,10649
|
|
3
|
+
fromargs/_argv.py,sha256=8Kn-zr4gtHqxgAnL3NtSPIMccc1DH9oktO9OPCxuP1E,6744
|
|
4
|
+
fromargs/_errors.py,sha256=RIMFarBk0TS_ov0FJ1GXAUvq9p0mOKTQvXwe5ViipNM,552
|
|
5
|
+
fromargs/_output.py,sha256=PyRmHjmNsnyTHRhYE2eyhpCyi5WftRugt8kzVMZJ2CE,2039
|
|
6
|
+
fromargs/_run.py,sha256=AVpLn_erfT9SotcyJOEM5Om7h9pawGXmiLdtzIQ_rzI,6925
|
|
7
|
+
fromargs/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
fromargs-0.1.0.dist-info/METADATA,sha256=ESD7qFvjP8x6usgdQ7n_qbXyfQDU0PQrTdzCGCyiRjI,5273
|
|
9
|
+
fromargs-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
10
|
+
fromargs-0.1.0.dist-info/licenses/LICENSE,sha256=lT2oNYDRyYDND9T_fYwnp7OrBEzjeEM5-1Nn9u6FL-g,1070
|
|
11
|
+
fromargs-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Sorensen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|