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.
Files changed (28) hide show
  1. {fromargs-0.1.0 → fromargs-0.2.0}/.gitignore +0 -3
  2. fromargs-0.2.0/CHANGELOG.md +76 -0
  3. {fromargs-0.1.0 → fromargs-0.2.0}/PKG-INFO +47 -11
  4. {fromargs-0.1.0 → fromargs-0.2.0}/README.md +44 -9
  5. {fromargs-0.1.0 → fromargs-0.2.0}/examples/cheese_cave.py +2 -2
  6. {fromargs-0.1.0 → fromargs-0.2.0}/pyproject.toml +3 -2
  7. {fromargs-0.1.0 → fromargs-0.2.0}/src/fromargs/_app.py +58 -27
  8. {fromargs-0.1.0 → fromargs-0.2.0}/src/fromargs/_argv.py +5 -5
  9. fromargs-0.2.0/src/fromargs/_errors.py +35 -0
  10. fromargs-0.2.0/src/fromargs/_output.py +84 -0
  11. {fromargs-0.1.0 → fromargs-0.2.0}/src/fromargs/_run.py +67 -22
  12. {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_argv.py +42 -0
  13. {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_output.py +95 -3
  14. {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_run.py +234 -5
  15. {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_surface.py +117 -0
  16. {fromargs-0.1.0 → fromargs-0.2.0}/uv.lock +5 -5
  17. fromargs-0.1.0/src/fromargs/_errors.py +0 -16
  18. fromargs-0.1.0/src/fromargs/_output.py +0 -49
  19. {fromargs-0.1.0 → fromargs-0.2.0}/LICENSE +0 -0
  20. {fromargs-0.1.0 → fromargs-0.2.0}/src/fromargs/__init__.py +0 -0
  21. {fromargs-0.1.0 → fromargs-0.2.0}/src/fromargs/py.typed +0 -0
  22. {fromargs-0.1.0 → fromargs-0.2.0}/tests/conftest.py +0 -0
  23. {fromargs-0.1.0 → fromargs-0.2.0}/tests/e2e/acceptance_cli.py +0 -0
  24. {fromargs-0.1.0 → fromargs-0.2.0}/tests/e2e/conftest.py +0 -0
  25. {fromargs-0.1.0 → fromargs-0.2.0}/tests/e2e/mutate.py +0 -0
  26. {fromargs-0.1.0 → fromargs-0.2.0}/tests/e2e/test_acceptance.py +0 -0
  27. {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_adversarial.py +0 -0
  28. {fromargs-0.1.0 → fromargs-0.2.0}/tests/test_example_cheese_cave.py +0 -0
@@ -16,8 +16,5 @@ __pycache__/
16
16
  # Skill-creator iteration workspaces (eval runs, benchmarks, viewer HTML)
17
17
  skills/*-workspace/
18
18
 
19
- # file-handler skill artifact tree (local scratch space)
20
- .skillz/
21
-
22
19
  # Serena MCP — machine-local; not tracked
23
20
  .serena/
@@ -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.1.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,>=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,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
- 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 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` 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.
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>=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,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
- 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 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` 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.
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, 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.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>=4.25.3,<5"]
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 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
@@ -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[0])
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``; 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)
@@ -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")