fromargs 0.1.0__tar.gz → 0.2.0__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.0}/.gitignore +0 -3
- fromargs-0.2.0/CHANGELOG.md +76 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/PKG-INFO +47 -11
- {fromargs-0.1.0 → fromargs-0.2.0}/README.md +44 -9
- {fromargs-0.1.0 → fromargs-0.2.0}/examples/cheese_cave.py +2 -2
- {fromargs-0.1.0 → fromargs-0.2.0}/pyproject.toml +3 -2
- {fromargs-0.1.0 → fromargs-0.2.0}/src/fromargs/_app.py +58 -27
- {fromargs-0.1.0 → fromargs-0.2.0}/src/fromargs/_argv.py +5 -5
- fromargs-0.2.0/src/fromargs/_errors.py +35 -0
- fromargs-0.2.0/src/fromargs/_output.py +84 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/src/fromargs/_run.py +67 -22
- {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_argv.py +42 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_output.py +95 -3
- {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_run.py +234 -5
- {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_surface.py +117 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/uv.lock +5 -5
- fromargs-0.1.0/src/fromargs/_errors.py +0 -16
- fromargs-0.1.0/src/fromargs/_output.py +0 -49
- {fromargs-0.1.0 → fromargs-0.2.0}/LICENSE +0 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/src/fromargs/__init__.py +0 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/src/fromargs/py.typed +0 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/tests/conftest.py +0 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/tests/e2e/acceptance_cli.py +0 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/tests/e2e/conftest.py +0 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/tests/e2e/mutate.py +0 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/tests/e2e/test_acceptance.py +0 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_adversarial.py +0 -0
- {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_example_cheese_cave.py +0 -0
|
@@ -0,0 +1,76 @@
|
|
|
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 `lib/fromargs/pyproject.toml`.
|
|
12
|
+
When the minor version changes, also bump the `fromargs` range in `pyproject.toml` and `lib/pyproject.toml`.
|
|
13
|
+
Then relock, and push a `fromargs-v<version>` tag after the release commit merges.
|
|
14
|
+
|
|
15
|
+
## [Unreleased]
|
|
16
|
+
|
|
17
|
+
## [0.2.0] - 2026-10-04
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Breaking:** require Cyclopts 5 (`cyclopts>=5,<6`).
|
|
22
|
+
Cyclopts 5 removes the v4 fuzzy command fallback.
|
|
23
|
+
A command name that differs only in dashes, underscores, or case no longer resolves.
|
|
24
|
+
It reports an `Unknown command ... Did you mean` error with exit code 2.
|
|
25
|
+
- **Breaking:** `CliError` accepts only an `int` `exit_code` from 2 to 255.
|
|
26
|
+
It raises a dedicated `Exception` subclass for 0, 1, an out-of-range code, a `bool`, or a non-`int` code.
|
|
27
|
+
Cyclopts reports a `ValueError` or `TypeError` as bad input at exit 2.
|
|
28
|
+
The dedicated subclass is neither, so a bad code in a converter or validator exits 1 with a traceback.
|
|
29
|
+
- **Breaking:** `App`, `App.group`, and `App.command` raise `ValueError` for the
|
|
30
|
+
Cyclopts options that `run()` never honors: `error_formatter`, `result_action`,
|
|
31
|
+
`suppress_keyboard_interrupt`, `print_error`, `exit_on_error`, `help_on_error`,
|
|
32
|
+
and `error_console`.
|
|
33
|
+
- A `set` or `frozenset` result prints as a sorted JSON list. Before, it exited 1.
|
|
34
|
+
- A `bytes` or iterator result raises `TypeError` with a message that names the fix.
|
|
35
|
+
- **Breaking:** `SystemExit` and `KeyboardInterrupt` no longer propagate from `App.run()`.
|
|
36
|
+
`run()` catches them while it parses, runs the handler, and prints the result.
|
|
37
|
+
`sys.exit(n)` sets the exit status with an error line.
|
|
38
|
+
An `int` outside 1 to 255 reports `exited with status <code>` at exit code 1.
|
|
39
|
+
`KeyboardInterrupt` reports exit code 130.
|
|
40
|
+
- The `--json` and `--full` flags are literal. Put them after `--` to pass them as data.
|
|
41
|
+
- `fromargs` keeps the traceback file after the process exits.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- A converter or validator exception other than `ValueError`, `TypeError`, or
|
|
46
|
+
`AssertionError` now reports the exit code 1 error line with a `traceback` path.
|
|
47
|
+
Before, it escaped `App.run()`. Cyclopts still reports those three types as usage errors at exit code 2.
|
|
48
|
+
A quote-split repair candidate that raises such an exception counts as rejected.
|
|
49
|
+
- **Breaking:** A `default_parameter` that a command inherits from its app or group now gets
|
|
50
|
+
the reserved `--json`/`--full` check. A reserved flag now raises `ValueError` at registration.
|
|
51
|
+
- `--version` resolves the caller's distribution when Python lists it twice
|
|
52
|
+
(for example under `uv run --with`), and under `python -m pkg.cli`.
|
|
53
|
+
- A leading `--json` or `--full` no longer hides a nested command's custom
|
|
54
|
+
`end_of_options_delimiter`.
|
|
55
|
+
|
|
56
|
+
## [0.1.0] - 2026-09-26
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- `fromargs.App`, which composes one `cyclopts.App` and registers commands
|
|
61
|
+
with `@app.command`, `@app.default`, and `app.group(name)`.
|
|
62
|
+
- Forced JSON output: a non-`None` return value prints as one JSON document
|
|
63
|
+
on stdout. `None` means exit 0 with no stdout.
|
|
64
|
+
- One JSON error line on stderr for every error:
|
|
65
|
+
`{"error": <message>, "exit_code": <n>}`.
|
|
66
|
+
- `fromargs.CliError` (exit code 2) and `fromargs.contract_error` (exit code 3).
|
|
67
|
+
- An unexpected handler exception reports exit code 1 with a `traceback` file path.
|
|
68
|
+
- The global `--json` (no-op) and `--full` (no truncation) flags.
|
|
69
|
+
- `limit=n` on `@app.command` and `@app.default` to truncate a sequence result.
|
|
70
|
+
- A verified quote-split repair for a shell-merged option value.
|
|
71
|
+
- `--version` resolution for the calling distribution, not for `fromargs`.
|
|
72
|
+
- Async handlers through `asyncio.run` when the effective backend is asyncio.
|
|
73
|
+
|
|
74
|
+
[Unreleased]: https://github.com/paulnsorensen/skillz-that-grillz/compare/fromargs-v0.2.0...HEAD
|
|
75
|
+
[0.2.0]: https://github.com/paulnsorensen/skillz-that-grillz/compare/fromargs-v0.1.0...fromargs-v0.2.0
|
|
76
|
+
[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.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Self-healing Cyclopts CLI helpers for agent-friendly command lines.
|
|
5
5
|
Project-URL: Homepage, https://github.com/paulnsorensen/skillz-that-grillz/tree/main/lib/fromargs
|
|
6
6
|
Project-URL: Source, https://github.com/paulnsorensen/skillz-that-grillz/tree/main/lib/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/lib/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,31 @@ 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 temporary file. `fromargs` keeps the file, so the caller can
|
|
102
|
+
read it after the process exits.
|
|
103
|
+
- `sys.exit(n)` sets the exit status, and `run()` catches it while it
|
|
104
|
+
parses, runs the handler, and prints the result. `0` or `None` exits `0`
|
|
105
|
+
with no output. An `int` from `1` to `255` reports an error envelope at
|
|
106
|
+
that exit code. An `int` outside that range reports
|
|
107
|
+
`exited with status <code>` at exit code `1`. Any other `sys.exit` value
|
|
108
|
+
reports its text at exit code `1`. `KeyboardInterrupt` reports
|
|
109
|
+
`{"error": "interrupted"}` at exit code `130`.
|
|
89
110
|
- A quote-split repair (below) prints one plain-text `note:` line on stderr;
|
|
90
111
|
it never changes stdout or the exit code.
|
|
91
112
|
|
|
@@ -100,12 +121,19 @@ anywhere before the end-of-options marker:
|
|
|
100
121
|
|
|
101
122
|
Neither flag reaches a handler, and neither is a real Cyclopts option.
|
|
102
123
|
|
|
124
|
+
The rule is literal. Every bare `--json` or `--full` token before the marker
|
|
125
|
+
is global, even where a parameter would take a hyphen-leading value (for
|
|
126
|
+
example `Parameter(allow_leading_hyphen=True)`). To pass one of these tokens
|
|
127
|
+
as data, put it after the marker (`--` by default): `wrap jq -- --json x`.
|
|
128
|
+
The `--full=1` and `--json=true` forms are not global flags. Cyclopts
|
|
129
|
+
rejects them as unknown options.
|
|
130
|
+
|
|
103
131
|
## `limit`
|
|
104
132
|
|
|
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.
|
|
133
|
+
`@app.command(limit=n)` truncates a sequence or set result to its first `n`
|
|
134
|
+
items, unless the caller passes `--full`. A set truncates after sorting.
|
|
135
|
+
Truncation prints a `note:` line on stderr and never applies to a mapping or
|
|
136
|
+
a string. `App.default` accepts the same `limit` keyword.
|
|
109
137
|
|
|
110
138
|
## `App.default`
|
|
111
139
|
|
|
@@ -146,3 +174,11 @@ version of `fromargs` itself. It resolves, in order:
|
|
|
146
174
|
`App.group(name, version=..., **cyclopts_kwargs)` forwards every extra
|
|
147
175
|
keyword, including `version`, to the nested `cyclopts.App`, so a group can
|
|
148
176
|
report its own version independently of the root app.
|
|
177
|
+
|
|
178
|
+
## Cyclopts options
|
|
179
|
+
|
|
180
|
+
`App`, `App.group`, and `App.command` forward keyword arguments to Cyclopts.
|
|
181
|
+
`run()` owns error output, the exit status, and the result. So these three
|
|
182
|
+
raise `ValueError` for a Cyclopts option that `run()` never honors:
|
|
183
|
+
`error_formatter`, `result_action`, `suppress_keyboard_interrupt`,
|
|
184
|
+
`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,31 @@ 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 temporary file. `fromargs` keeps the file, so the caller can
|
|
79
|
+
read it after the process exits.
|
|
80
|
+
- `sys.exit(n)` sets the exit status, and `run()` catches it while it
|
|
81
|
+
parses, runs the handler, and prints the result. `0` or `None` exits `0`
|
|
82
|
+
with no output. An `int` from `1` to `255` reports an error envelope at
|
|
83
|
+
that exit code. An `int` outside that range reports
|
|
84
|
+
`exited with status <code>` at exit code `1`. Any other `sys.exit` value
|
|
85
|
+
reports its text at exit code `1`. `KeyboardInterrupt` reports
|
|
86
|
+
`{"error": "interrupted"}` at exit code `130`.
|
|
67
87
|
- A quote-split repair (below) prints one plain-text `note:` line on stderr;
|
|
68
88
|
it never changes stdout or the exit code.
|
|
69
89
|
|
|
@@ -78,12 +98,19 @@ anywhere before the end-of-options marker:
|
|
|
78
98
|
|
|
79
99
|
Neither flag reaches a handler, and neither is a real Cyclopts option.
|
|
80
100
|
|
|
101
|
+
The rule is literal. Every bare `--json` or `--full` token before the marker
|
|
102
|
+
is global, even where a parameter would take a hyphen-leading value (for
|
|
103
|
+
example `Parameter(allow_leading_hyphen=True)`). To pass one of these tokens
|
|
104
|
+
as data, put it after the marker (`--` by default): `wrap jq -- --json x`.
|
|
105
|
+
The `--full=1` and `--json=true` forms are not global flags. Cyclopts
|
|
106
|
+
rejects them as unknown options.
|
|
107
|
+
|
|
81
108
|
## `limit`
|
|
82
109
|
|
|
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.
|
|
110
|
+
`@app.command(limit=n)` truncates a sequence or set result to its first `n`
|
|
111
|
+
items, unless the caller passes `--full`. A set truncates after sorting.
|
|
112
|
+
Truncation prints a `note:` line on stderr and never applies to a mapping or
|
|
113
|
+
a string. `App.default` accepts the same `limit` keyword.
|
|
87
114
|
|
|
88
115
|
## `App.default`
|
|
89
116
|
|
|
@@ -124,3 +151,11 @@ version of `fromargs` itself. It resolves, in order:
|
|
|
124
151
|
`App.group(name, version=..., **cyclopts_kwargs)` forwards every extra
|
|
125
152
|
keyword, including `version`, to the nested `cyclopts.App`, so a group can
|
|
126
153
|
report its own version independently of the root app.
|
|
154
|
+
|
|
155
|
+
## Cyclopts options
|
|
156
|
+
|
|
157
|
+
`App`, `App.group`, and `App.command` forward keyword arguments to Cyclopts.
|
|
158
|
+
`run()` owns error output, the exit status, and the result. So these three
|
|
159
|
+
raise `ValueError` for a Cyclopts option that `run()` never honors:
|
|
160
|
+
`error_formatter`, `result_action`, `suppress_keyboard_interrupt`,
|
|
161
|
+
`print_error`, `exit_on_error`, `help_on_error`, and `error_console`.
|
|
@@ -5,11 +5,11 @@ The CLI tracks wheels of cheese as they ripen. Run it from the repository root:
|
|
|
5
5
|
uv run --project lib/fromargs python lib/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.
|
|
3
|
+
version = "0.2.0"
|
|
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,12 +17,13 @@ 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
23
|
Homepage = "https://github.com/paulnsorensen/skillz-that-grillz/tree/main/lib/fromargs"
|
|
24
24
|
Source = "https://github.com/paulnsorensen/skillz-that-grillz/tree/main/lib/fromargs"
|
|
25
25
|
Issues = "https://github.com/paulnsorensen/skillz-that-grillz/issues"
|
|
26
|
+
Changelog = "https://github.com/paulnsorensen/skillz-that-grillz/blob/main/lib/fromargs/CHANGELOG.md"
|
|
26
27
|
|
|
27
28
|
[dependency-groups]
|
|
28
29
|
dev = ["pytest>=8", "basedpyright==1.40.1"]
|
|
@@ -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
|
|
@@ -145,13 +164,14 @@ class App:
|
|
|
145
164
|
return self.command(handler, name=name, limit=limit, help=help, **kwargs)
|
|
146
165
|
|
|
147
166
|
return register
|
|
167
|
+
_reject_inert(kwargs)
|
|
148
168
|
_check_limit(limit)
|
|
149
169
|
before = set(self._cyclopts)
|
|
150
170
|
_ = self._cyclopts.command(obj, name=name, help=help, **kwargs)
|
|
151
171
|
registered = sorted(set(self._cyclopts) - before)
|
|
152
172
|
sub_app = self._cyclopts[registered[0]]
|
|
153
173
|
try:
|
|
154
|
-
_reject_reserved(sub_app)
|
|
174
|
+
_reject_reserved(sub_app, (*self._ancestors, self._cyclopts))
|
|
155
175
|
except ValueError:
|
|
156
176
|
for key in registered:
|
|
157
177
|
del self._cyclopts[key]
|
|
@@ -206,7 +226,7 @@ class App:
|
|
|
206
226
|
except cyclopts.CommandCollisionError as exc:
|
|
207
227
|
raise ValueError(str(exc)) from exc
|
|
208
228
|
try:
|
|
209
|
-
_reject_reserved(self._cyclopts)
|
|
229
|
+
_reject_reserved(self._cyclopts, self._ancestors)
|
|
210
230
|
except ValueError:
|
|
211
231
|
self._cyclopts.default_command = previous
|
|
212
232
|
self._cyclopts.validator = previous_validator
|
|
@@ -217,11 +237,12 @@ class App:
|
|
|
217
237
|
|
|
218
238
|
def group(self, name: str, *, help: str | None = None, **cyclopts_kwargs: Unpack[_AppKwargs]) -> App:
|
|
219
239
|
"""Return a nested command group registered under this app."""
|
|
240
|
+
_reject_inert(cyclopts_kwargs)
|
|
220
241
|
sub = cyclopts.App(name=name, help=help, **cyclopts_kwargs)
|
|
221
242
|
if cyclopts_kwargs.get("default_command") is not None:
|
|
222
|
-
_reject_reserved(sub)
|
|
243
|
+
_reject_reserved(sub, (*self._ancestors, self._cyclopts))
|
|
223
244
|
_ = self._cyclopts.command(sub)
|
|
224
|
-
return App._wrap(sub, self._limits)
|
|
245
|
+
return App._wrap(sub, self._limits, (*self._ancestors, self._cyclopts))
|
|
225
246
|
|
|
226
247
|
def run(self, argv: Sequence[str] | None = None, *, stdout: TextIO | None = None) -> int:
|
|
227
248
|
"""Parse argv once, invoke one handler, and return its exit status."""
|
|
@@ -232,10 +253,22 @@ class App:
|
|
|
232
253
|
sys.exit(self.run())
|
|
233
254
|
|
|
234
255
|
|
|
235
|
-
def _reserved_option(app: cyclopts.App) -> str | None:
|
|
236
|
-
"""The first reserved global flag name ``app``'s assembled arguments claim, or ``None``.
|
|
256
|
+
def _reserved_option(app: cyclopts.App, ancestors: tuple[cyclopts.App, ...]) -> str | None:
|
|
257
|
+
"""The first reserved global flag name ``app``'s assembled arguments claim, or ``None``.
|
|
258
|
+
|
|
259
|
+
Cyclopts resolves an inherited ``default_parameter`` from the app stack,
|
|
260
|
+
which it fills only while parsing. Combine the ``default_parameter`` of
|
|
261
|
+
each ancestor so ``app`` sees the parent chain.
|
|
262
|
+
"""
|
|
263
|
+
inherited = [
|
|
264
|
+
parameter
|
|
265
|
+
for parameter in (*(a.default_parameter for a in ancestors), app.default_parameter)
|
|
266
|
+
if parameter is not None
|
|
267
|
+
]
|
|
237
268
|
try:
|
|
238
|
-
arguments = app.assemble_argument_collection(
|
|
269
|
+
arguments = app.assemble_argument_collection(
|
|
270
|
+
default_parameter=cyclopts.Parameter.combine(*inherited)
|
|
271
|
+
)
|
|
239
272
|
except ValueError:
|
|
240
273
|
return None
|
|
241
274
|
names: set[str] = set()
|
|
@@ -245,9 +278,9 @@ def _reserved_option(app: cyclopts.App) -> str | None:
|
|
|
245
278
|
return reserved[0] if reserved else None
|
|
246
279
|
|
|
247
280
|
|
|
248
|
-
def _reject_reserved(app: cyclopts.App) -> None:
|
|
281
|
+
def _reject_reserved(app: cyclopts.App, ancestors: tuple[cyclopts.App, ...]) -> None:
|
|
249
282
|
"""Raise ``ValueError`` when ``app``'s assembled arguments claim a reserved global flag."""
|
|
250
|
-
reserved = _reserved_option(app)
|
|
283
|
+
reserved = _reserved_option(app, ancestors)
|
|
251
284
|
if reserved is not None:
|
|
252
285
|
raise ValueError(f"command option {reserved!r} is reserved by fromargs")
|
|
253
286
|
|
|
@@ -272,16 +305,14 @@ def _caller_version(module_globals: dict[str, object]) -> Callable[[], str]:
|
|
|
272
305
|
spec_name = getattr(module_globals.get("__spec__"), "name", None)
|
|
273
306
|
module_name = (
|
|
274
307
|
spec_name
|
|
275
|
-
if caller_name == "__main__"
|
|
276
|
-
and isinstance(spec_name, str)
|
|
277
|
-
and spec_name.endswith(".__main__")
|
|
308
|
+
if caller_name == "__main__" and isinstance(spec_name, str) and spec_name
|
|
278
309
|
else caller_name
|
|
279
310
|
)
|
|
280
311
|
root = module_name.split(".")[0]
|
|
281
312
|
candidates = [root]
|
|
282
|
-
providers = metadata.packages_distributions().get(root, [])
|
|
313
|
+
providers = set(metadata.packages_distributions().get(root, []))
|
|
283
314
|
if len(providers) == 1:
|
|
284
|
-
candidates.append(providers
|
|
315
|
+
candidates.append(next(iter(providers)))
|
|
285
316
|
for candidate in candidates:
|
|
286
317
|
try:
|
|
287
318
|
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
|
|
|
@@ -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)
|
|
@@ -0,0 +1,84 @@
|
|
|
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 Iterator, Mapping, Sequence, Set
|
|
11
|
+
from typing import TextIO, cast
|
|
12
|
+
|
|
13
|
+
_BYTES_LIKE = (bytes, bytearray, memoryview)
|
|
14
|
+
# Sequences that serialize as one JSON value, never as a list of items.
|
|
15
|
+
_SCALAR_SEQUENCES = (str, *_BYTES_LIKE)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def write_result(
|
|
19
|
+
value: object, *, limit: int | None, full: bool, stdout: TextIO | None = None
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Print ``value`` as one JSON document; truncate a long sequence unless ``full``.
|
|
22
|
+
|
|
23
|
+
A sequence longer than ``limit`` is cut to its first ``limit`` items and
|
|
24
|
+
stderr gets a ``note:`` line, unless ``full`` is true or ``limit`` is
|
|
25
|
+
``None``. A set becomes a sorted list first, so it truncates the same way.
|
|
26
|
+
Truncation never applies to a mapping or a string. ``NaN`` and
|
|
27
|
+
``Infinity`` are rejected, and a type ``_default`` cannot resolve (bytes,
|
|
28
|
+
iterators) raises ``TypeError`` instead of printing a misleading fallback.
|
|
29
|
+
"""
|
|
30
|
+
stream = stdout if stdout is not None else sys.stdout
|
|
31
|
+
payload = _sorted_items(value) if isinstance(value, Set) else value
|
|
32
|
+
note: str | None = None
|
|
33
|
+
if limit is not None and isinstance(payload, Sequence) and not isinstance(payload, _SCALAR_SEQUENCES):
|
|
34
|
+
items = cast("Sequence[object]", payload)
|
|
35
|
+
total = len(items)
|
|
36
|
+
if not full and total > limit:
|
|
37
|
+
payload = list(itertools.islice(items, limit))
|
|
38
|
+
note = f"note: showing {limit} of {total}; pass --full for the rest"
|
|
39
|
+
serialized = json.dumps(payload, indent=2, default=_default, allow_nan=False)
|
|
40
|
+
if note is not None:
|
|
41
|
+
print(note, file=sys.stderr)
|
|
42
|
+
print(serialized, file=stream)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sorted_items(items: object) -> list[object]:
|
|
46
|
+
"""Order set items deterministically.
|
|
47
|
+
|
|
48
|
+
Use natural order when no member is a ``Set``. A ``Set`` member (``<`` means
|
|
49
|
+
subset, so natural order is not total) or a ``TypeError`` from natural order
|
|
50
|
+
sorts by canonical JSON text instead. ``repr`` is not usable here, because
|
|
51
|
+
the ``repr`` of a multi-element frozenset depends on hash order.
|
|
52
|
+
"""
|
|
53
|
+
members = list(cast("Set[object]", items))
|
|
54
|
+
if not any(isinstance(member, Set) for member in members):
|
|
55
|
+
try:
|
|
56
|
+
return cast("list[object]", sorted(members)) # pyright: ignore[reportArgumentType]
|
|
57
|
+
except TypeError:
|
|
58
|
+
pass
|
|
59
|
+
return sorted(members, key=lambda item: json.dumps(item, sort_keys=True, default=_default))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _default(value: object) -> object:
|
|
63
|
+
"""Fallback for ``json.dumps``: dataclass via ``asdict``, path via ``str``, mapping, set, or list.
|
|
64
|
+
|
|
65
|
+
A nested set becomes a sorted list. Bytes and iterators raise ``TypeError``
|
|
66
|
+
with a hint, because bytes have no unambiguous JSON form and an iterator
|
|
67
|
+
has no known length for ``limit``.
|
|
68
|
+
"""
|
|
69
|
+
if dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
70
|
+
return dataclasses.asdict(value)
|
|
71
|
+
if isinstance(value, os.PathLike):
|
|
72
|
+
return str(value)
|
|
73
|
+
if isinstance(value, Mapping):
|
|
74
|
+
return dict(cast("Mapping[object, object]", value))
|
|
75
|
+
if isinstance(value, Set):
|
|
76
|
+
return _sorted_items(value)
|
|
77
|
+
if isinstance(value, Sequence) and not isinstance(value, _SCALAR_SEQUENCES):
|
|
78
|
+
return list(value)
|
|
79
|
+
name = type(value).__name__
|
|
80
|
+
if isinstance(value, _BYTES_LIKE):
|
|
81
|
+
raise TypeError(f"{name} is not JSON serializable; return a str (decode it) or a list of ints")
|
|
82
|
+
if isinstance(value, Iterator):
|
|
83
|
+
raise TypeError(f"{name} is not JSON serializable; return a list instead of an iterator")
|
|
84
|
+
raise TypeError(f"Object of type {name} is not JSON serializable")
|