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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.