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.
Files changed (31) hide show
  1. {fromargs-0.1.0 → fromargs-0.2.1}/.gitignore +5 -2
  2. fromargs-0.2.1/CHANGELOG.md +95 -0
  3. {fromargs-0.1.0 → fromargs-0.2.1}/PKG-INFO +51 -13
  4. {fromargs-0.1.0 → fromargs-0.2.1}/README.md +46 -9
  5. {fromargs-0.1.0 → fromargs-0.2.1}/examples/cheese_cave.py +3 -3
  6. {fromargs-0.1.0 → fromargs-0.2.1}/pyproject.toml +10 -6
  7. {fromargs-0.1.0 → fromargs-0.2.1}/src/fromargs/_app.py +72 -31
  8. {fromargs-0.1.0 → fromargs-0.2.1}/src/fromargs/_argv.py +6 -6
  9. fromargs-0.2.1/src/fromargs/_errors.py +35 -0
  10. fromargs-0.2.1/src/fromargs/_output.py +84 -0
  11. {fromargs-0.1.0 → fromargs-0.2.1}/src/fromargs/_run.py +110 -26
  12. fromargs-0.2.1/tests/conftest.py +54 -0
  13. {fromargs-0.1.0 → fromargs-0.2.1}/tests/e2e/conftest.py +2 -2
  14. {fromargs-0.1.0 → fromargs-0.2.1}/tests/e2e/mutate.py +18 -18
  15. {fromargs-0.1.0 → fromargs-0.2.1}/tests/e2e/test_acceptance.py +31 -31
  16. fromargs-0.2.1/tests/test_ac_marker.py +40 -0
  17. {fromargs-0.1.0 → fromargs-0.2.1}/tests/test_argv.py +98 -0
  18. fromargs-0.2.1/tests/test_mutate_selection.py +124 -0
  19. {fromargs-0.1.0 → fromargs-0.2.1}/tests/test_output.py +98 -4
  20. {fromargs-0.1.0 → fromargs-0.2.1}/tests/test_run.py +320 -14
  21. {fromargs-0.1.0 → fromargs-0.2.1}/tests/test_surface.py +141 -0
  22. fromargs-0.1.0/src/fromargs/_errors.py +0 -16
  23. fromargs-0.1.0/src/fromargs/_output.py +0 -49
  24. fromargs-0.1.0/tests/conftest.py +0 -25
  25. fromargs-0.1.0/uv.lock +0 -195
  26. {fromargs-0.1.0 → fromargs-0.2.1}/LICENSE +0 -0
  27. {fromargs-0.1.0 → fromargs-0.2.1}/src/fromargs/__init__.py +0 -0
  28. {fromargs-0.1.0 → fromargs-0.2.1}/src/fromargs/py.typed +0 -0
  29. {fromargs-0.1.0 → fromargs-0.2.1}/tests/e2e/acceptance_cli.py +0 -0
  30. {fromargs-0.1.0 → fromargs-0.2.1}/tests/test_adversarial.py +0 -0
  31. {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
- # file-handler skill artifact tree (local scratch space)
20
- .skillz/
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.0
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/lib/fromargs
6
- Project-URL: Source, https://github.com/paulnsorensen/skillz-that-grillz/tree/main/lib/fromargs
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,>=4.25.3
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>=4.25.3,<5`; it does not yet track Cyclopts 5.
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
- 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`.
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` 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.
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>=4.25.3,<5`; it does not yet track Cyclopts 5.
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
- Raise `fromargs.CliError(message)` for exit code `2`, or
64
- `fromargs.contract_error(exc, context=...)` to wrap a caught exception at
65
- exit code `3`. An unhandled Cyclopts parse error also reports at exit
66
- code `2`.
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` items,
84
- unless the caller passes `--full`. Truncation prints a `note:` line on
85
- stderr and never applies to a mapping or a string. `App.default` accepts the
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 lib/fromargs python lib/fromargs/examples/cheese_cave.py wheels list
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, and ``run`` prints one ``note:`` line on stderr for each repair:
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" # both repairs
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.0"
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>=4.25.3,<5"]
20
+ dependencies = ["cyclopts>=5,<6"]
21
21
 
22
22
  [project.urls]
23
- Homepage = "https://github.com/paulnsorensen/skillz-that-grillz/tree/main/lib/fromargs"
24
- Source = "https://github.com/paulnsorensen/skillz-that-grillz/tree/main/lib/fromargs"
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.1"]
29
+ dev = ["pytest>=8", "pytest-xdist>=3", "basedpyright==1.40.2"]
29
30
 
30
31
  [build-system]
31
- requires = ["hatchling>=1.27.0"]
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 future cyclopts
36
- 4.x release can add a keyword here before fromargs re-releases with it.
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
- error_formatter: Callable[[cyclopts.CycloptsError], object] | None
75
- result_action: cyclopts.ResultAction | None
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
- _ = self._cyclopts.command(obj, name=name, help=help, **kwargs)
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
- _ = self._cyclopts.command(sub)
224
- return App._wrap(sub, self._limits)
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[0])
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 = token.partition("=")[0] if token.startswith("--") else previous
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``; a converter's ``CliError`` counts as a rejection."""
91
+ """Probe-parse ``argv``; any converter or validator exception counts as a rejection."""
94
92
  try:
95
93
  _ = parse_once(app, argv)
96
- except (CycloptsError, CliError):
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)