fromargs 0.1.0__tar.gz → 0.2.1__tar.gz
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-0.1.0 → fromargs-0.2.1}/.gitignore +5 -2
- fromargs-0.2.1/CHANGELOG.md +95 -0
- {fromargs-0.1.0 → fromargs-0.2.1}/PKG-INFO +51 -13
- {fromargs-0.1.0 → fromargs-0.2.1}/README.md +46 -9
- {fromargs-0.1.0 → fromargs-0.2.1}/examples/cheese_cave.py +3 -3
- {fromargs-0.1.0 → fromargs-0.2.1}/pyproject.toml +10 -6
- {fromargs-0.1.0 → fromargs-0.2.1}/src/fromargs/_app.py +72 -31
- {fromargs-0.1.0 → fromargs-0.2.1}/src/fromargs/_argv.py +6 -6
- fromargs-0.2.1/src/fromargs/_errors.py +35 -0
- fromargs-0.2.1/src/fromargs/_output.py +84 -0
- {fromargs-0.1.0 → fromargs-0.2.1}/src/fromargs/_run.py +110 -26
- fromargs-0.2.1/tests/conftest.py +54 -0
- {fromargs-0.1.0 → fromargs-0.2.1}/tests/e2e/conftest.py +2 -2
- {fromargs-0.1.0 → fromargs-0.2.1}/tests/e2e/mutate.py +18 -18
- {fromargs-0.1.0 → fromargs-0.2.1}/tests/e2e/test_acceptance.py +31 -31
- fromargs-0.2.1/tests/test_ac_marker.py +40 -0
- {fromargs-0.1.0 → fromargs-0.2.1}/tests/test_argv.py +98 -0
- fromargs-0.2.1/tests/test_mutate_selection.py +124 -0
- {fromargs-0.1.0 → fromargs-0.2.1}/tests/test_output.py +98 -4
- {fromargs-0.1.0 → fromargs-0.2.1}/tests/test_run.py +320 -14
- {fromargs-0.1.0 → fromargs-0.2.1}/tests/test_surface.py +141 -0
- fromargs-0.1.0/src/fromargs/_errors.py +0 -16
- fromargs-0.1.0/src/fromargs/_output.py +0 -49
- fromargs-0.1.0/tests/conftest.py +0 -25
- fromargs-0.1.0/uv.lock +0 -195
- {fromargs-0.1.0 → fromargs-0.2.1}/LICENSE +0 -0
- {fromargs-0.1.0 → fromargs-0.2.1}/src/fromargs/__init__.py +0 -0
- {fromargs-0.1.0 → fromargs-0.2.1}/src/fromargs/py.typed +0 -0
- {fromargs-0.1.0 → fromargs-0.2.1}/tests/e2e/acceptance_cli.py +0 -0
- {fromargs-0.1.0 → fromargs-0.2.1}/tests/test_adversarial.py +0 -0
- {fromargs-0.1.0 → fromargs-0.2.1}/tests/test_example_cheese_cave.py +0 -0
|
@@ -13,11 +13,14 @@ __pycache__/
|
|
|
13
13
|
.cheese/
|
|
14
14
|
.context/
|
|
15
15
|
|
|
16
|
+
# Skill archives: build output. `just build` writes them; CI publishes them on dist (ADR-012).
|
|
17
|
+
skills/*/scripts/*.pyz
|
|
18
|
+
|
|
16
19
|
# Skill-creator iteration workspaces (eval runs, benchmarks, viewer HTML)
|
|
17
20
|
skills/*-workspace/
|
|
18
21
|
|
|
19
|
-
#
|
|
20
|
-
.
|
|
22
|
+
# Live smoke run records (just live-smoke)
|
|
23
|
+
.live-smoke/
|
|
21
24
|
|
|
22
25
|
# Serena MCP — machine-local; not tracked
|
|
23
26
|
.serena/
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
This file records the notable changes to the `fromargs` distribution.
|
|
4
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
The project uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
A `0.x` minor release can contain breaking changes.
|
|
7
|
+
|
|
8
|
+
The `publish fromargs` workflow reads the section for the tagged version.
|
|
9
|
+
It uses that section as the GitHub release notes.
|
|
10
|
+
To release, move the `Unreleased` entries into a new version section with the date.
|
|
11
|
+
Then set the same version in `packages/fromargs/pyproject.toml`.
|
|
12
|
+
When the minor version changes, also bump the `fromargs` range in the root `pyproject.toml` and `packages/skillz/pyproject.toml`.
|
|
13
|
+
Then relock, and push a `fromargs-v<version>` tag after the release commit merges.
|
|
14
|
+
|
|
15
|
+
## [Unreleased]
|
|
16
|
+
|
|
17
|
+
## [0.2.1] - 2026-10-25
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- An unexpected exception now writes its traceback file to a private per-user directory under the temp root.
|
|
22
|
+
On POSIX the directory is `fromargs-<uid>`; `fromargs` refuses a symlink there and removes group and other permission bits.
|
|
23
|
+
The directory keeps only the newest 20 traceback files, so an older path from the envelope can disappear.
|
|
24
|
+
Before, each failure left a new `fromargs-*.traceback` file in the shared temp root, without a limit.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Quote repair now splits an option merged with its value, such as `"--count 3"`.
|
|
29
|
+
Before, `run()` rejected that token as an unknown option at exit 2.
|
|
30
|
+
The repair still skips free-text `str` and path options.
|
|
31
|
+
- `App.command` and `App.group` raise `ValueError` for a name that is already registered.
|
|
32
|
+
Before, they raised `cyclopts.CommandCollisionError`, which is not a `ValueError`.
|
|
33
|
+
`App.default` already raised `ValueError` for a second default handler.
|
|
34
|
+
|
|
35
|
+
## [0.2.0] - 2026-10-04
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Breaking:** require Cyclopts 5 (`cyclopts>=5,<6`).
|
|
40
|
+
Cyclopts 5 removes the v4 fuzzy command fallback.
|
|
41
|
+
A command name that differs only in dashes, underscores, or case no longer resolves.
|
|
42
|
+
It reports an `Unknown command ... Did you mean` error with exit code 2.
|
|
43
|
+
- **Breaking:** `CliError` accepts only an `int` `exit_code` from 2 to 255.
|
|
44
|
+
It raises a dedicated `Exception` subclass for 0, 1, an out-of-range code, a `bool`, or a non-`int` code.
|
|
45
|
+
Cyclopts reports a `ValueError` or `TypeError` as bad input at exit 2.
|
|
46
|
+
The dedicated subclass is neither, so a bad code in a converter or validator exits 1 with a traceback.
|
|
47
|
+
- **Breaking:** `App`, `App.group`, and `App.command` raise `ValueError` for the
|
|
48
|
+
Cyclopts options that `run()` never honors: `error_formatter`, `result_action`,
|
|
49
|
+
`suppress_keyboard_interrupt`, `print_error`, `exit_on_error`, `help_on_error`,
|
|
50
|
+
and `error_console`.
|
|
51
|
+
- A `set` or `frozenset` result prints as a sorted JSON list. Before, it exited 1.
|
|
52
|
+
- A `bytes` or iterator result raises `TypeError` with a message that names the fix.
|
|
53
|
+
- **Breaking:** `SystemExit` and `KeyboardInterrupt` no longer propagate from `App.run()`.
|
|
54
|
+
`run()` catches them while it parses, runs the handler, and prints the result.
|
|
55
|
+
`sys.exit(n)` sets the exit status with an error line.
|
|
56
|
+
An `int` outside 1 to 255 reports `exited with status <code>` at exit code 1.
|
|
57
|
+
`KeyboardInterrupt` reports exit code 130.
|
|
58
|
+
- The `--json` and `--full` flags are literal. Put them after `--` to pass them as data.
|
|
59
|
+
- `fromargs` keeps the traceback file after the process exits.
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- A converter or validator exception other than `ValueError`, `TypeError`, or
|
|
64
|
+
`AssertionError` now reports the exit code 1 error line with a `traceback` path.
|
|
65
|
+
Before, it escaped `App.run()`. Cyclopts still reports those three types as usage errors at exit code 2.
|
|
66
|
+
A quote-split repair candidate that raises such an exception counts as rejected.
|
|
67
|
+
- **Breaking:** A `default_parameter` that a command inherits from its app or group now gets
|
|
68
|
+
the reserved `--json`/`--full` check. A reserved flag now raises `ValueError` at registration.
|
|
69
|
+
- `--version` resolves the caller's distribution when Python lists it twice
|
|
70
|
+
(for example under `uv run --with`), and under `python -m pkg.cli`.
|
|
71
|
+
- A leading `--json` or `--full` no longer hides a nested command's custom
|
|
72
|
+
`end_of_options_delimiter`.
|
|
73
|
+
|
|
74
|
+
## [0.1.0] - 2026-09-26
|
|
75
|
+
|
|
76
|
+
### Added
|
|
77
|
+
|
|
78
|
+
- `fromargs.App`, which composes one `cyclopts.App` and registers commands
|
|
79
|
+
with `@app.command`, `@app.default`, and `app.group(name)`.
|
|
80
|
+
- Forced JSON output: a non-`None` return value prints as one JSON document
|
|
81
|
+
on stdout. `None` means exit 0 with no stdout.
|
|
82
|
+
- One JSON error line on stderr for every error:
|
|
83
|
+
`{"error": <message>, "exit_code": <n>}`.
|
|
84
|
+
- `fromargs.CliError` (exit code 2) and `fromargs.contract_error` (exit code 3).
|
|
85
|
+
- An unexpected handler exception reports exit code 1 with a `traceback` file path.
|
|
86
|
+
- The global `--json` (no-op) and `--full` (no truncation) flags.
|
|
87
|
+
- `limit=n` on `@app.command` and `@app.default` to truncate a sequence result.
|
|
88
|
+
- A verified quote-split repair for a shell-merged option value.
|
|
89
|
+
- `--version` resolution for the calling distribution, not for `fromargs`.
|
|
90
|
+
- Async handlers through `asyncio.run` when the effective backend is asyncio.
|
|
91
|
+
|
|
92
|
+
[Unreleased]: https://github.com/paulnsorensen/skillz-that-grillz/compare/fromargs-v0.2.1...HEAD
|
|
93
|
+
[0.2.1]: https://github.com/paulnsorensen/skillz-that-grillz/compare/fromargs-v0.2.0...fromargs-v0.2.1
|
|
94
|
+
[0.2.0]: https://github.com/paulnsorensen/skillz-that-grillz/compare/fromargs-v0.1.0...fromargs-v0.2.0
|
|
95
|
+
[0.1.0]: https://github.com/paulnsorensen/skillz-that-grillz/releases/tag/fromargs-v0.1.0
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: fromargs
|
|
3
|
-
Version: 0.1
|
|
3
|
+
Version: 0.2.1
|
|
4
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/
|
|
6
|
-
Project-URL: Source, https://github.com/paulnsorensen/skillz-that-grillz/tree/main/
|
|
5
|
+
Project-URL: Homepage, https://github.com/paulnsorensen/skillz-that-grillz/tree/main/packages/fromargs
|
|
6
|
+
Project-URL: Source, https://github.com/paulnsorensen/skillz-that-grillz/tree/main/packages/fromargs
|
|
7
7
|
Project-URL: Issues, https://github.com/paulnsorensen/skillz-that-grillz/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/paulnsorensen/skillz-that-grillz/blob/main/packages/fromargs/CHANGELOG.md
|
|
8
9
|
Author-email: Paul Sorensen <paulnsorensen@gmail.com>
|
|
9
10
|
License-Expression: MIT
|
|
10
11
|
License-File: LICENSE
|
|
@@ -17,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
17
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
18
19
|
Classifier: Typing :: Typed
|
|
19
20
|
Requires-Python: >=3.11
|
|
20
|
-
Requires-Dist: cyclopts<5
|
|
21
|
+
Requires-Dist: cyclopts<6,>=5
|
|
21
22
|
Description-Content-Type: text/markdown
|
|
22
23
|
|
|
23
24
|
# fromargs
|
|
@@ -35,7 +36,7 @@ uv add fromargs
|
|
|
35
36
|
pip install fromargs
|
|
36
37
|
```
|
|
37
38
|
|
|
38
|
-
`fromargs` pins `cyclopts>=
|
|
39
|
+
`fromargs` pins `cyclopts>=5,<6`.
|
|
39
40
|
|
|
40
41
|
## Quick start
|
|
41
42
|
|
|
@@ -81,11 +82,33 @@ a truncated list result.
|
|
|
81
82
|
- A handler returns data, not text. A non-`None` return value prints as one
|
|
82
83
|
JSON document on stdout, then the process exits `0`.
|
|
83
84
|
- A `None` return value means exit `0` with no stdout.
|
|
85
|
+
- A `set` or `frozenset` prints as a sorted JSON list. Items that cannot
|
|
86
|
+
sort together order by their canonical JSON text, so the output stays
|
|
87
|
+
stable. `bytes`, `bytearray`, `memoryview`, and iterators (such as
|
|
88
|
+
generators) raise `TypeError`: return a `str` or a `list` instead.
|
|
84
89
|
- Every error is one JSON line on stderr: `{"error": <message>, "exit_code": <n>}`.
|
|
85
|
-
|
|
86
|
-
`
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
- Raise `fromargs.CliError(message)` for exit code `2`. Pass
|
|
91
|
+
`exit_code=n` for a code from `2` to `255`. `CliError` rejects any other
|
|
92
|
+
code with an error that is neither `ValueError` nor `TypeError`.
|
|
93
|
+
Cyclopts reports those as bad input. Exit `0` means success, and exit `1`
|
|
94
|
+
means an unexpected exception.
|
|
95
|
+
- Use `fromargs.contract_error(exc, context=...)` to wrap a caught exception
|
|
96
|
+
at exit code `3`. A Cyclopts parse error reports at exit code `2`.
|
|
97
|
+
- A converter or validator that raises `ValueError`, `TypeError`, or
|
|
98
|
+
`AssertionError` is a usage error at exit code `2`. Any other exception
|
|
99
|
+
from a converter, a validator, the handler, or the result serialization
|
|
100
|
+
reports at exit code `1`. Its envelope adds a `traceback` key with the
|
|
101
|
+
path of a file in a per-user directory under the temp root
|
|
102
|
+
(`fromargs-<uid>` on POSIX). The directory has no group or other
|
|
103
|
+
permission bits. `fromargs` keeps the newest 20 traceback files there and
|
|
104
|
+
deletes older ones, so read the file soon after the process exits.
|
|
105
|
+
- `sys.exit(n)` sets the exit status, and `run()` catches it while it
|
|
106
|
+
parses, runs the handler, and prints the result. `0` or `None` exits `0`
|
|
107
|
+
with no output. An `int` from `1` to `255` reports an error envelope at
|
|
108
|
+
that exit code. An `int` outside that range reports
|
|
109
|
+
`exited with status <code>` at exit code `1`. Any other `sys.exit` value
|
|
110
|
+
reports its text at exit code `1`. `KeyboardInterrupt` reports
|
|
111
|
+
`{"error": "interrupted"}` at exit code `130`.
|
|
89
112
|
- A quote-split repair (below) prints one plain-text `note:` line on stderr;
|
|
90
113
|
it never changes stdout or the exit code.
|
|
91
114
|
|
|
@@ -100,12 +123,19 @@ anywhere before the end-of-options marker:
|
|
|
100
123
|
|
|
101
124
|
Neither flag reaches a handler, and neither is a real Cyclopts option.
|
|
102
125
|
|
|
126
|
+
The rule is literal. Every bare `--json` or `--full` token before the marker
|
|
127
|
+
is global, even where a parameter would take a hyphen-leading value (for
|
|
128
|
+
example `Parameter(allow_leading_hyphen=True)`). To pass one of these tokens
|
|
129
|
+
as data, put it after the marker (`--` by default): `wrap jq -- --json x`.
|
|
130
|
+
The `--full=1` and `--json=true` forms are not global flags. Cyclopts
|
|
131
|
+
rejects them as unknown options.
|
|
132
|
+
|
|
103
133
|
## `limit`
|
|
104
134
|
|
|
105
|
-
`@app.command(limit=n)` truncates a sequence result to its first `n`
|
|
106
|
-
unless the caller passes `--full`.
|
|
107
|
-
stderr and never applies to a mapping or
|
|
108
|
-
same `limit` keyword.
|
|
135
|
+
`@app.command(limit=n)` truncates a sequence or set result to its first `n`
|
|
136
|
+
items, unless the caller passes `--full`. A set truncates after sorting.
|
|
137
|
+
Truncation prints a `note:` line on stderr and never applies to a mapping or
|
|
138
|
+
a string. `App.default` accepts the same `limit` keyword.
|
|
109
139
|
|
|
110
140
|
## `App.default`
|
|
111
141
|
|
|
@@ -146,3 +176,11 @@ version of `fromargs` itself. It resolves, in order:
|
|
|
146
176
|
`App.group(name, version=..., **cyclopts_kwargs)` forwards every extra
|
|
147
177
|
keyword, including `version`, to the nested `cyclopts.App`, so a group can
|
|
148
178
|
report its own version independently of the root app.
|
|
179
|
+
|
|
180
|
+
## Cyclopts options
|
|
181
|
+
|
|
182
|
+
`App`, `App.group`, and `App.command` forward keyword arguments to Cyclopts.
|
|
183
|
+
`run()` owns error output, the exit status, and the result. So these three
|
|
184
|
+
raise `ValueError` for a Cyclopts option that `run()` never honors:
|
|
185
|
+
`error_formatter`, `result_action`, `suppress_keyboard_interrupt`,
|
|
186
|
+
`print_error`, `exit_on_error`, `help_on_error`, and `error_console`.
|
|
@@ -13,7 +13,7 @@ uv add fromargs
|
|
|
13
13
|
pip install fromargs
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
`fromargs` pins `cyclopts>=
|
|
16
|
+
`fromargs` pins `cyclopts>=5,<6`.
|
|
17
17
|
|
|
18
18
|
## Quick start
|
|
19
19
|
|
|
@@ -59,11 +59,33 @@ a truncated list result.
|
|
|
59
59
|
- A handler returns data, not text. A non-`None` return value prints as one
|
|
60
60
|
JSON document on stdout, then the process exits `0`.
|
|
61
61
|
- A `None` return value means exit `0` with no stdout.
|
|
62
|
+
- A `set` or `frozenset` prints as a sorted JSON list. Items that cannot
|
|
63
|
+
sort together order by their canonical JSON text, so the output stays
|
|
64
|
+
stable. `bytes`, `bytearray`, `memoryview`, and iterators (such as
|
|
65
|
+
generators) raise `TypeError`: return a `str` or a `list` instead.
|
|
62
66
|
- Every error is one JSON line on stderr: `{"error": <message>, "exit_code": <n>}`.
|
|
63
|
-
|
|
64
|
-
`
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
- Raise `fromargs.CliError(message)` for exit code `2`. Pass
|
|
68
|
+
`exit_code=n` for a code from `2` to `255`. `CliError` rejects any other
|
|
69
|
+
code with an error that is neither `ValueError` nor `TypeError`.
|
|
70
|
+
Cyclopts reports those as bad input. Exit `0` means success, and exit `1`
|
|
71
|
+
means an unexpected exception.
|
|
72
|
+
- Use `fromargs.contract_error(exc, context=...)` to wrap a caught exception
|
|
73
|
+
at exit code `3`. A Cyclopts parse error reports at exit code `2`.
|
|
74
|
+
- A converter or validator that raises `ValueError`, `TypeError`, or
|
|
75
|
+
`AssertionError` is a usage error at exit code `2`. Any other exception
|
|
76
|
+
from a converter, a validator, the handler, or the result serialization
|
|
77
|
+
reports at exit code `1`. Its envelope adds a `traceback` key with the
|
|
78
|
+
path of a file in a per-user directory under the temp root
|
|
79
|
+
(`fromargs-<uid>` on POSIX). The directory has no group or other
|
|
80
|
+
permission bits. `fromargs` keeps the newest 20 traceback files there and
|
|
81
|
+
deletes older ones, so read the file soon after the process exits.
|
|
82
|
+
- `sys.exit(n)` sets the exit status, and `run()` catches it while it
|
|
83
|
+
parses, runs the handler, and prints the result. `0` or `None` exits `0`
|
|
84
|
+
with no output. An `int` from `1` to `255` reports an error envelope at
|
|
85
|
+
that exit code. An `int` outside that range reports
|
|
86
|
+
`exited with status <code>` at exit code `1`. Any other `sys.exit` value
|
|
87
|
+
reports its text at exit code `1`. `KeyboardInterrupt` reports
|
|
88
|
+
`{"error": "interrupted"}` at exit code `130`.
|
|
67
89
|
- A quote-split repair (below) prints one plain-text `note:` line on stderr;
|
|
68
90
|
it never changes stdout or the exit code.
|
|
69
91
|
|
|
@@ -78,12 +100,19 @@ anywhere before the end-of-options marker:
|
|
|
78
100
|
|
|
79
101
|
Neither flag reaches a handler, and neither is a real Cyclopts option.
|
|
80
102
|
|
|
103
|
+
The rule is literal. Every bare `--json` or `--full` token before the marker
|
|
104
|
+
is global, even where a parameter would take a hyphen-leading value (for
|
|
105
|
+
example `Parameter(allow_leading_hyphen=True)`). To pass one of these tokens
|
|
106
|
+
as data, put it after the marker (`--` by default): `wrap jq -- --json x`.
|
|
107
|
+
The `--full=1` and `--json=true` forms are not global flags. Cyclopts
|
|
108
|
+
rejects them as unknown options.
|
|
109
|
+
|
|
81
110
|
## `limit`
|
|
82
111
|
|
|
83
|
-
`@app.command(limit=n)` truncates a sequence result to its first `n`
|
|
84
|
-
unless the caller passes `--full`.
|
|
85
|
-
stderr and never applies to a mapping or
|
|
86
|
-
same `limit` keyword.
|
|
112
|
+
`@app.command(limit=n)` truncates a sequence or set result to its first `n`
|
|
113
|
+
items, unless the caller passes `--full`. A set truncates after sorting.
|
|
114
|
+
Truncation prints a `note:` line on stderr and never applies to a mapping or
|
|
115
|
+
a string. `App.default` accepts the same `limit` keyword.
|
|
87
116
|
|
|
88
117
|
## `App.default`
|
|
89
118
|
|
|
@@ -124,3 +153,11 @@ version of `fromargs` itself. It resolves, in order:
|
|
|
124
153
|
`App.group(name, version=..., **cyclopts_kwargs)` forwards every extra
|
|
125
154
|
keyword, including `version`, to the nested `cyclopts.App`, so a group can
|
|
126
155
|
report its own version independently of the root app.
|
|
156
|
+
|
|
157
|
+
## Cyclopts options
|
|
158
|
+
|
|
159
|
+
`App`, `App.group`, and `App.command` forward keyword arguments to Cyclopts.
|
|
160
|
+
`run()` owns error output, the exit status, and the result. So these three
|
|
161
|
+
raise `ValueError` for a Cyclopts option that `run()` never honors:
|
|
162
|
+
`error_formatter`, `result_action`, `suppress_keyboard_interrupt`,
|
|
163
|
+
`print_error`, `exit_on_error`, `help_on_error`, and `error_console`.
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
The CLI tracks wheels of cheese as they ripen. Run it from the repository root:
|
|
4
4
|
|
|
5
|
-
uv run --project
|
|
5
|
+
uv run --project packages/fromargs python packages/fromargs/examples/cheese_cave.py wheels list
|
|
6
6
|
|
|
7
7
|
Agents often send malformed argv. Each call below still runs the intended
|
|
8
|
-
command
|
|
8
|
+
command. ``run`` prints one ``note:`` line on stderr only for quote-split repairs:
|
|
9
9
|
|
|
10
10
|
cheese_cave.py --json wheels list # --json is a no-op, dropped
|
|
11
11
|
cheese_cave.py age brie --weeks "2 --dry-run" # splits the merged argument
|
|
12
|
-
cheese_cave.py --json age brie --weeks "2 --dry-run" #
|
|
12
|
+
cheese_cave.py --json age brie --weeks "2 --dry-run" # drops --json silently; notes the split
|
|
13
13
|
|
|
14
14
|
Cyclopts itself binds ``--dry_run`` to ``--dry-run``. It also answers a
|
|
15
15
|
misspelled command such as ``wheels lst`` with ``Did you mean "list"?``.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "fromargs"
|
|
3
|
-
version = "0.1
|
|
3
|
+
version = "0.2.1"
|
|
4
4
|
description = "Self-healing Cyclopts CLI helpers for agent-friendly command lines."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.11"
|
|
@@ -17,18 +17,19 @@ classifiers = [
|
|
|
17
17
|
"Environment :: Console",
|
|
18
18
|
"Intended Audience :: Developers",
|
|
19
19
|
]
|
|
20
|
-
dependencies = ["cyclopts>=
|
|
20
|
+
dependencies = ["cyclopts>=5,<6"]
|
|
21
21
|
|
|
22
22
|
[project.urls]
|
|
23
|
-
Homepage = "https://github.com/paulnsorensen/skillz-that-grillz/tree/main/
|
|
24
|
-
Source = "https://github.com/paulnsorensen/skillz-that-grillz/tree/main/
|
|
23
|
+
Homepage = "https://github.com/paulnsorensen/skillz-that-grillz/tree/main/packages/fromargs"
|
|
24
|
+
Source = "https://github.com/paulnsorensen/skillz-that-grillz/tree/main/packages/fromargs"
|
|
25
25
|
Issues = "https://github.com/paulnsorensen/skillz-that-grillz/issues"
|
|
26
|
+
Changelog = "https://github.com/paulnsorensen/skillz-that-grillz/blob/main/packages/fromargs/CHANGELOG.md"
|
|
26
27
|
|
|
27
28
|
[dependency-groups]
|
|
28
|
-
dev = ["pytest>=8", "basedpyright==1.40.
|
|
29
|
+
dev = ["pytest>=8", "pytest-xdist>=3", "basedpyright==1.40.2"]
|
|
29
30
|
|
|
30
31
|
[build-system]
|
|
31
|
-
requires = ["hatchling>=1.
|
|
32
|
+
requires = ["hatchling>=1.32.4"]
|
|
32
33
|
build-backend = "hatchling.build"
|
|
33
34
|
|
|
34
35
|
[tool.hatch.build.targets.wheel]
|
|
@@ -49,5 +50,8 @@ addopts = "--strict-markers"
|
|
|
49
50
|
|
|
50
51
|
[tool.basedpyright]
|
|
51
52
|
pythonVersion = "3.11"
|
|
53
|
+
# Workspace members share the root .venv.
|
|
54
|
+
venvPath = "../.."
|
|
55
|
+
venv = ".venv"
|
|
52
56
|
include = ["src", "tests", "examples"]
|
|
53
57
|
extraPaths = ["examples"]
|
|
@@ -11,7 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import inspect
|
|
13
13
|
import sys
|
|
14
|
-
from collections.abc import Callable, Coroutine, Iterable, Sequence
|
|
14
|
+
from collections.abc import Callable, Coroutine, Iterable, Mapping, Sequence
|
|
15
15
|
from importlib import metadata
|
|
16
16
|
from typing import TYPE_CHECKING, Literal, TextIO, TypedDict, TypeVar, Unpack, overload
|
|
17
17
|
|
|
@@ -32,8 +32,9 @@ _HelpFormat = Literal["markdown", "md", "plaintext", "restructuredtext", "rst",
|
|
|
32
32
|
class _AppKwargs(TypedDict, total=False):
|
|
33
33
|
"""Keyword-only ``cyclopts.App`` constructor arguments this wrapper forwards untouched.
|
|
34
34
|
|
|
35
|
-
This mirrors the ``cyclopts.App`` constructor by hand; a
|
|
36
|
-
|
|
35
|
+
This mirrors the ``cyclopts.App`` constructor by hand; a new cyclopts 5.x
|
|
36
|
+
keyword needs an entry here before fromargs accepts it. This type omits
|
|
37
|
+
the options that ``run`` never honors; see ``_INERT_KWARGS``.
|
|
37
38
|
"""
|
|
38
39
|
|
|
39
40
|
usage: str | None
|
|
@@ -50,10 +51,8 @@ class _AppKwargs(TypedDict, total=False):
|
|
|
50
51
|
version_flags: str | Iterable[str] | None
|
|
51
52
|
show: bool
|
|
52
53
|
console: Console | None
|
|
53
|
-
error_console: Console | None
|
|
54
54
|
help_flags: str | Iterable[str] | None
|
|
55
55
|
help_format: _HelpFormat | None
|
|
56
|
-
help_on_error: bool | None
|
|
57
56
|
help_prologue: str | None
|
|
58
57
|
help_epilogue: str | None
|
|
59
58
|
version_format: _HelpFormat | None
|
|
@@ -65,14 +64,31 @@ class _AppKwargs(TypedDict, total=False):
|
|
|
65
64
|
name_transform: Callable[[str], str] | None
|
|
66
65
|
sort_key: object
|
|
67
66
|
end_of_options_delimiter: str | None
|
|
68
|
-
print_error: bool | None
|
|
69
|
-
exit_on_error: bool | None
|
|
70
67
|
verbose: bool | None
|
|
71
|
-
suppress_keyboard_interrupt: bool
|
|
72
68
|
backend: Literal["asyncio", "trio"] | None
|
|
73
69
|
help_formatter: Literal["default", "plain"] | HelpFormatter | None
|
|
74
|
-
|
|
75
|
-
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ``run`` parses with printing, exiting, and help-on-error forced off and owns
|
|
73
|
+
# all error output, the exit status, and the result, so these do nothing.
|
|
74
|
+
_INERT_KWARGS = frozenset(
|
|
75
|
+
{
|
|
76
|
+
"error_formatter",
|
|
77
|
+
"result_action",
|
|
78
|
+
"suppress_keyboard_interrupt",
|
|
79
|
+
"print_error",
|
|
80
|
+
"exit_on_error",
|
|
81
|
+
"help_on_error",
|
|
82
|
+
"error_console",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _reject_inert(kwargs: Mapping[str, object]) -> None:
|
|
88
|
+
"""Raise ``ValueError`` when ``kwargs`` names a Cyclopts option that ``run`` ignores."""
|
|
89
|
+
inert = sorted(_INERT_KWARGS.intersection(kwargs))
|
|
90
|
+
if inert:
|
|
91
|
+
raise ValueError(f"cyclopts option {inert[0]!r} has no effect under fromargs; remove it")
|
|
76
92
|
|
|
77
93
|
|
|
78
94
|
class App:
|
|
@@ -85,21 +101,24 @@ class App:
|
|
|
85
101
|
help: str | None = None,
|
|
86
102
|
**cyclopts_kwargs: Unpack[_AppKwargs],
|
|
87
103
|
) -> None:
|
|
104
|
+
_reject_inert(cyclopts_kwargs)
|
|
88
105
|
if "version" not in cyclopts_kwargs:
|
|
89
106
|
frame = inspect.currentframe()
|
|
90
107
|
caller = frame.f_back if frame is not None else None
|
|
91
108
|
cyclopts_kwargs["version"] = _caller_version(caller.f_globals if caller is not None else {})
|
|
92
109
|
self._cyclopts: cyclopts.App = cyclopts.App(name=name, help=help, **cyclopts_kwargs)
|
|
93
110
|
self._limits: dict[int, int] = {}
|
|
111
|
+
self._ancestors: tuple[cyclopts.App, ...] = ()
|
|
94
112
|
if cyclopts_kwargs.get("default_command") is not None:
|
|
95
|
-
_reject_reserved(self._cyclopts)
|
|
113
|
+
_reject_reserved(self._cyclopts, self._ancestors)
|
|
96
114
|
|
|
97
115
|
@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."""
|
|
116
|
+
def _wrap(cls, cyclopts_app: cyclopts.App, limits: dict[int, int], ancestors: tuple[cyclopts.App, ...]) -> App:
|
|
117
|
+
"""Wrap an existing ``cyclopts.App`` under ``ancestors`` without building a new one."""
|
|
100
118
|
wrapper = cls.__new__(cls)
|
|
101
119
|
wrapper._cyclopts = cyclopts_app
|
|
102
120
|
wrapper._limits = limits
|
|
121
|
+
wrapper._ancestors = ancestors
|
|
103
122
|
return wrapper
|
|
104
123
|
|
|
105
124
|
@overload
|
|
@@ -137,7 +156,8 @@ class App:
|
|
|
137
156
|
|
|
138
157
|
``limit`` truncates a sequence result to its first ``limit`` items
|
|
139
158
|
unless ``--full`` is passed; it must not be negative. ``obj`` must
|
|
140
|
-
not declare a CLI option named ``--json`` or ``--full``.
|
|
159
|
+
not declare a CLI option named ``--json`` or ``--full``. Raises
|
|
160
|
+
``ValueError`` when the command name is already registered.
|
|
141
161
|
"""
|
|
142
162
|
if obj is None:
|
|
143
163
|
|
|
@@ -145,13 +165,17 @@ class App:
|
|
|
145
165
|
return self.command(handler, name=name, limit=limit, help=help, **kwargs)
|
|
146
166
|
|
|
147
167
|
return register
|
|
168
|
+
_reject_inert(kwargs)
|
|
148
169
|
_check_limit(limit)
|
|
149
170
|
before = set(self._cyclopts)
|
|
150
|
-
|
|
171
|
+
try:
|
|
172
|
+
_ = self._cyclopts.command(obj, name=name, help=help, **kwargs)
|
|
173
|
+
except cyclopts.CommandCollisionError as exc:
|
|
174
|
+
raise ValueError(str(exc)) from exc
|
|
151
175
|
registered = sorted(set(self._cyclopts) - before)
|
|
152
176
|
sub_app = self._cyclopts[registered[0]]
|
|
153
177
|
try:
|
|
154
|
-
_reject_reserved(sub_app)
|
|
178
|
+
_reject_reserved(sub_app, (*self._ancestors, self._cyclopts))
|
|
155
179
|
except ValueError:
|
|
156
180
|
for key in registered:
|
|
157
181
|
del self._cyclopts[key]
|
|
@@ -206,7 +230,7 @@ class App:
|
|
|
206
230
|
except cyclopts.CommandCollisionError as exc:
|
|
207
231
|
raise ValueError(str(exc)) from exc
|
|
208
232
|
try:
|
|
209
|
-
_reject_reserved(self._cyclopts)
|
|
233
|
+
_reject_reserved(self._cyclopts, self._ancestors)
|
|
210
234
|
except ValueError:
|
|
211
235
|
self._cyclopts.default_command = previous
|
|
212
236
|
self._cyclopts.validator = previous_validator
|
|
@@ -216,12 +240,19 @@ class App:
|
|
|
216
240
|
return obj
|
|
217
241
|
|
|
218
242
|
def group(self, name: str, *, help: str | None = None, **cyclopts_kwargs: Unpack[_AppKwargs]) -> App:
|
|
219
|
-
"""Return a nested command group registered under this app.
|
|
243
|
+
"""Return a nested command group registered under this app.
|
|
244
|
+
|
|
245
|
+
Raises ``ValueError`` when ``name`` is already registered.
|
|
246
|
+
"""
|
|
247
|
+
_reject_inert(cyclopts_kwargs)
|
|
220
248
|
sub = cyclopts.App(name=name, help=help, **cyclopts_kwargs)
|
|
221
249
|
if cyclopts_kwargs.get("default_command") is not None:
|
|
222
|
-
_reject_reserved(sub)
|
|
223
|
-
|
|
224
|
-
|
|
250
|
+
_reject_reserved(sub, (*self._ancestors, self._cyclopts))
|
|
251
|
+
try:
|
|
252
|
+
_ = self._cyclopts.command(sub)
|
|
253
|
+
except cyclopts.CommandCollisionError as exc:
|
|
254
|
+
raise ValueError(str(exc)) from exc
|
|
255
|
+
return App._wrap(sub, self._limits, (*self._ancestors, self._cyclopts))
|
|
225
256
|
|
|
226
257
|
def run(self, argv: Sequence[str] | None = None, *, stdout: TextIO | None = None) -> int:
|
|
227
258
|
"""Parse argv once, invoke one handler, and return its exit status."""
|
|
@@ -232,10 +263,22 @@ class App:
|
|
|
232
263
|
sys.exit(self.run())
|
|
233
264
|
|
|
234
265
|
|
|
235
|
-
def _reserved_option(app: cyclopts.App) -> str | None:
|
|
236
|
-
"""The first reserved global flag name ``app``'s assembled arguments claim, or ``None``.
|
|
266
|
+
def _reserved_option(app: cyclopts.App, ancestors: tuple[cyclopts.App, ...]) -> str | None:
|
|
267
|
+
"""The first reserved global flag name ``app``'s assembled arguments claim, or ``None``.
|
|
268
|
+
|
|
269
|
+
Cyclopts resolves an inherited ``default_parameter`` from the app stack,
|
|
270
|
+
which it fills only while parsing. Combine the ``default_parameter`` of
|
|
271
|
+
each ancestor so ``app`` sees the parent chain.
|
|
272
|
+
"""
|
|
273
|
+
inherited = [
|
|
274
|
+
parameter
|
|
275
|
+
for parameter in (*(a.default_parameter for a in ancestors), app.default_parameter)
|
|
276
|
+
if parameter is not None
|
|
277
|
+
]
|
|
237
278
|
try:
|
|
238
|
-
arguments = app.assemble_argument_collection(
|
|
279
|
+
arguments = app.assemble_argument_collection(
|
|
280
|
+
default_parameter=cyclopts.Parameter.combine(*inherited)
|
|
281
|
+
)
|
|
239
282
|
except ValueError:
|
|
240
283
|
return None
|
|
241
284
|
names: set[str] = set()
|
|
@@ -245,9 +288,9 @@ def _reserved_option(app: cyclopts.App) -> str | None:
|
|
|
245
288
|
return reserved[0] if reserved else None
|
|
246
289
|
|
|
247
290
|
|
|
248
|
-
def _reject_reserved(app: cyclopts.App) -> None:
|
|
291
|
+
def _reject_reserved(app: cyclopts.App, ancestors: tuple[cyclopts.App, ...]) -> None:
|
|
249
292
|
"""Raise ``ValueError`` when ``app``'s assembled arguments claim a reserved global flag."""
|
|
250
|
-
reserved = _reserved_option(app)
|
|
293
|
+
reserved = _reserved_option(app, ancestors)
|
|
251
294
|
if reserved is not None:
|
|
252
295
|
raise ValueError(f"command option {reserved!r} is reserved by fromargs")
|
|
253
296
|
|
|
@@ -272,16 +315,14 @@ def _caller_version(module_globals: dict[str, object]) -> Callable[[], str]:
|
|
|
272
315
|
spec_name = getattr(module_globals.get("__spec__"), "name", None)
|
|
273
316
|
module_name = (
|
|
274
317
|
spec_name
|
|
275
|
-
if caller_name == "__main__"
|
|
276
|
-
and isinstance(spec_name, str)
|
|
277
|
-
and spec_name.endswith(".__main__")
|
|
318
|
+
if caller_name == "__main__" and isinstance(spec_name, str) and spec_name
|
|
278
319
|
else caller_name
|
|
279
320
|
)
|
|
280
321
|
root = module_name.split(".")[0]
|
|
281
322
|
candidates = [root]
|
|
282
|
-
providers = metadata.packages_distributions().get(root, [])
|
|
323
|
+
providers = set(metadata.packages_distributions().get(root, []))
|
|
283
324
|
if len(providers) == 1:
|
|
284
|
-
candidates.append(providers
|
|
325
|
+
candidates.append(next(iter(providers)))
|
|
285
326
|
for candidate in candidates:
|
|
286
327
|
try:
|
|
287
328
|
return metadata.version(candidate)
|
|
@@ -18,8 +18,6 @@ from typing import cast, get_args, get_origin
|
|
|
18
18
|
from cyclopts import App, CycloptsError
|
|
19
19
|
from cyclopts.annotations import is_union
|
|
20
20
|
|
|
21
|
-
from fromargs._errors import CliError
|
|
22
|
-
|
|
23
21
|
GLOBAL_FLAGS = frozenset({"--json", "--full"})
|
|
24
22
|
|
|
25
23
|
|
|
@@ -52,7 +50,7 @@ def repair_rejected(app: App, argv: Sequence[str]) -> list[str] | None:
|
|
|
52
50
|
if pieces is None or control_flags.intersection(pieces):
|
|
53
51
|
continue
|
|
54
52
|
previous = original[index - 1] if index else ""
|
|
55
|
-
option =
|
|
53
|
+
option = pieces[0].partition("=")[0] if token.startswith("--") else previous
|
|
56
54
|
if option not in splittable:
|
|
57
55
|
continue
|
|
58
56
|
candidate = original[:index] + pieces + original[index + 1 :]
|
|
@@ -90,10 +88,10 @@ def parse_once(app: App, argv: Sequence[str]) -> tuple[Callable[..., object], Bo
|
|
|
90
88
|
|
|
91
89
|
|
|
92
90
|
def _parses(app: App, argv: Sequence[str]) -> bool:
|
|
93
|
-
"""Probe-parse ``argv``;
|
|
91
|
+
"""Probe-parse ``argv``; any converter or validator exception counts as a rejection."""
|
|
94
92
|
try:
|
|
95
93
|
_ = parse_once(app, argv)
|
|
96
|
-
except
|
|
94
|
+
except Exception:
|
|
97
95
|
return False
|
|
98
96
|
return True
|
|
99
97
|
|
|
@@ -112,9 +110,11 @@ def _options(app: App, argv: Sequence[str]) -> list[str]:
|
|
|
112
110
|
"""The tokens before the command's end-of-options marker (``--`` by default).
|
|
113
111
|
|
|
114
112
|
The innermost command app with a configured marker decides, as in Cyclopts.
|
|
113
|
+
The chain resolves without the global flags, so a leading ``--full`` does
|
|
114
|
+
not hide a nested app's marker.
|
|
115
115
|
"""
|
|
116
116
|
tokens = list(argv)
|
|
117
|
-
apps = command_chain(app, tokens) or (app,)
|
|
117
|
+
apps = command_chain(app, [t for t in tokens if t not in GLOBAL_FLAGS]) or (app,)
|
|
118
118
|
configured = [
|
|
119
119
|
command_app.end_of_options_delimiter
|
|
120
120
|
for command_app in apps
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""The error type that ``run`` reports as one stderr line."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InvalidExitCodeError(Exception):
|
|
7
|
+
"""A ``CliError`` got an invalid ``exit_code``; a programmer bug.
|
|
8
|
+
|
|
9
|
+
It derives from ``Exception`` alone. Cyclopts reports ``ValueError``,
|
|
10
|
+
``TypeError``, and ``AssertionError`` from a converter or validator as
|
|
11
|
+
bad user input at exit 2. This bug must not take that path. It must
|
|
12
|
+
reach ``run`` as an unexpected exception: exit 1 with a traceback.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CliError(Exception):
|
|
17
|
+
"""One-line error; ``run`` reports it on stderr and returns ``exit_code``.
|
|
18
|
+
|
|
19
|
+
``exit_code`` must be an ``int`` from 2 to 255. Exit 0 means success, exit 1
|
|
20
|
+
means an unexpected exception, and POSIX truncates statuses above 255.
|
|
21
|
+
Otherwise ``InvalidExitCodeError`` (not a ``ValueError`` or ``TypeError``).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, message: str, *, exit_code: int = 2) -> None:
|
|
25
|
+
if isinstance(exit_code, bool) or not isinstance(exit_code, int): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
26
|
+
raise InvalidExitCodeError(f"exit_code must be an int, got {type(exit_code).__name__}")
|
|
27
|
+
if not 2 <= exit_code <= 255:
|
|
28
|
+
raise InvalidExitCodeError(f"exit_code must be 2..255, got {exit_code}")
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.exit_code: int = exit_code
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def contract_error(exc: Exception, *, context: str) -> CliError:
|
|
34
|
+
"""Wrap a contract-violation exception as a ``CliError`` that exits 3."""
|
|
35
|
+
return CliError(f"{context}: {exc}", exit_code=3)
|