strictcli 0.9.0__tar.gz → 0.10.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 (122) hide show
  1. strictcli-0.10.0/.rlsbl/changes/.validated +1 -0
  2. strictcli-0.10.0/.rlsbl/changes/0.10.0.jsonl +6 -0
  3. strictcli-0.10.0/.rlsbl/changes/0.10.0.md +6 -0
  4. strictcli-0.10.0/.rlsbl/changes/0.9.1.jsonl +9 -0
  5. strictcli-0.10.0/.rlsbl/changes/0.9.1.md +9 -0
  6. strictcli-0.10.0/.rlsbl/releases/v0.9.0.toml +3 -0
  7. strictcli-0.10.0/.rlsbl/releases/v0.9.1.toml +3 -0
  8. strictcli-0.10.0/.rlsbl/version +1 -0
  9. {strictcli-0.9.0 → strictcli-0.10.0}/CHANGELOG.md +17 -0
  10. {strictcli-0.9.0 → strictcli-0.10.0}/PKG-INFO +1 -1
  11. {strictcli-0.9.0 → strictcli-0.10.0}/package-lock.json +2 -2
  12. {strictcli-0.9.0 → strictcli-0.10.0}/package.json +1 -1
  13. {strictcli-0.9.0 → strictcli-0.10.0}/pyproject.toml +1 -1
  14. {strictcli-0.9.0 → strictcli-0.10.0}/strictcli/__init__.py +216 -40
  15. strictcli-0.10.0/tests/test_at_prefix.py +342 -0
  16. strictcli-0.10.0/tests/test_config_file_path.py +60 -0
  17. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_dump_schema.py +232 -13
  18. {strictcli-0.9.0 → strictcli-0.10.0}/uv.lock +1 -1
  19. strictcli-0.9.0/.rlsbl/changes/.validated +0 -1
  20. strictcli-0.9.0/.rlsbl/version +0 -1
  21. {strictcli-0.9.0 → strictcli-0.10.0}/.claude/settings.json +0 -0
  22. {strictcli-0.9.0 → strictcli-0.10.0}/.github/workflows/ci.yml +0 -0
  23. {strictcli-0.9.0 → strictcli-0.10.0}/.github/workflows/publish.yml +0 -0
  24. {strictcli-0.9.0 → strictcli-0.10.0}/.gitignore +0 -0
  25. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/.claude/settings.json +0 -0
  26. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  27. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  28. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/.gitignore +0 -0
  29. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/hooks/post-release.sh +0 -0
  30. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +0 -0
  31. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +0 -0
  32. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/lint/go.toml +0 -0
  33. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/lint/npm.toml +0 -0
  34. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  35. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/CHANGELOG.md +0 -0
  36. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/CLAUDE.md +0 -0
  37. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/bases/LICENSE +0 -0
  38. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.4.0.jsonl +0 -0
  39. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.4.0.md +0 -0
  40. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.4.1.jsonl +0 -0
  41. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.4.1.md +0 -0
  42. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.5.0.jsonl +0 -0
  43. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.5.0.md +0 -0
  44. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.6.0.jsonl +0 -0
  45. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.6.0.md +0 -0
  46. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.6.1.jsonl +0 -0
  47. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.6.1.md +0 -0
  48. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.7.0.jsonl +0 -0
  49. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.7.0.md +0 -0
  50. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.7.1.jsonl +0 -0
  51. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.7.1.md +0 -0
  52. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.0.jsonl +0 -0
  53. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.0.md +0 -0
  54. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.1.jsonl +0 -0
  55. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.1.md +0 -0
  56. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.2.jsonl +0 -0
  57. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.2.md +0 -0
  58. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.3.jsonl +0 -0
  59. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.3.md +0 -0
  60. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.4.jsonl +0 -0
  61. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.4.md +0 -0
  62. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.5.jsonl +0 -0
  63. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.5.md +0 -0
  64. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.6.jsonl +0 -0
  65. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.6.md +0 -0
  66. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.7.jsonl +0 -0
  67. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.8.7.md +0 -0
  68. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.9.0.jsonl +0 -0
  69. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/0.9.0.md +0 -0
  70. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/changes/unreleased.jsonl +0 -0
  71. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/config.json +0 -0
  72. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/hashes.json +0 -0
  73. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/hooks/post-release.sh +0 -0
  74. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/hooks/pre-checks.sh +0 -0
  75. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/hooks/pre-release.sh +0 -0
  76. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/lint/go.toml +0 -0
  77. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/lint/npm.toml +0 -0
  78. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/lint/python.toml +0 -0
  79. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/releases/unreleased.toml +0 -0
  80. /strictcli-0.9.0/.rlsbl/releases/v0.9.0.toml → /strictcli-0.10.0/.rlsbl/releases/v0.10.0.toml +0 -0
  81. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/releases/v0.8.5.toml +0 -0
  82. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/releases/v0.8.6.toml +0 -0
  83. {strictcli-0.9.0 → strictcli-0.10.0}/.rlsbl/releases/v0.8.7.toml +0 -0
  84. {strictcli-0.9.0 → strictcli-0.10.0}/.strictcli/schema.json +0 -0
  85. {strictcli-0.9.0 → strictcli-0.10.0}/CLAUDE.md +0 -0
  86. {strictcli-0.9.0 → strictcli-0.10.0}/LICENSE +0 -0
  87. {strictcli-0.9.0 → strictcli-0.10.0}/README.md +0 -0
  88. {strictcli-0.9.0 → strictcli-0.10.0}/index.js +0 -0
  89. {strictcli-0.9.0 → strictcli-0.10.0}/postinstall.js +0 -0
  90. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_arg_default.py +0 -0
  91. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_auto_version.py +0 -0
  92. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_check_command.py +0 -0
  93. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_check_discovery.py +0 -0
  94. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_check_runner.py +0 -0
  95. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_check_schema.py +0 -0
  96. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_check_types.py +0 -0
  97. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_choices.py +0 -0
  98. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_command_help_suggestion.py +0 -0
  99. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_config.py +0 -0
  100. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_deep_nesting.py +0 -0
  101. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_dependencies.py +0 -0
  102. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_deprecated.py +0 -0
  103. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_e2e.py +0 -0
  104. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_env.py +0 -0
  105. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_exit_codes.py +0 -0
  106. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_float_type.py +0 -0
  107. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_global_flags.py +0 -0
  108. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_help.py +0 -0
  109. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_int_type.py +0 -0
  110. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_mutex.py +0 -0
  111. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_nesting.py +0 -0
  112. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_parser.py +0 -0
  113. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_passthrough.py +0 -0
  114. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_registration.py +0 -0
  115. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_repeatable.py +0 -0
  116. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_tagdsl.py +0 -0
  117. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_tags.py +0 -0
  118. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_toml_loading.py +0 -0
  119. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_validate.py +0 -0
  120. {strictcli-0.9.0 → strictcli-0.10.0}/tests/test_variadic.py +0 -0
  121. {strictcli-0.9.0 → strictcli-0.10.0}/todo/.defer/deferred.md +0 -0
  122. {strictcli-0.9.0 → strictcli-0.10.0}/todo/.done/original-idea.md +0 -0
@@ -0,0 +1 @@
1
+ 241a0b1102d02359dc80c640d8c8a17697f05a9b
@@ -0,0 +1,6 @@
1
+ {"commits":["faa8f348f03793084361365f15bd9f8be06046ff"],"user_facing":true,"description":"**New feature.** `--dump-schema` omits fields matching defaults and includes a top-level `defaults` object documenting what missing fields mean.","type":"feature"}
2
+ {"commits":["ef852d540829f5fb248c380b649e8c15e56b849b"],"user_facing":true,"description":"**New feature.** `@`-prefix for string flag values: `@path` reads from file, `@-` reads from stdin, `@@` escapes. 1 MB size limit, trailing whitespace stripped.","type":"feature"}
3
+ {"commits":["4294effc300d83eeba90c3cb90c6429fc443c929","e7e5bc79004fe8c0883fdc5c34a1f36eb8d97e75","6bf56e841883195ea6045386b21f93a4d1c8ee2d"],"user_facing":false}
4
+ {"commits":["05d896e80e8df10ee737f50ae735e99695bbc213","1e354f1681539c39d71e4df7d57090d6b32d3f59","5f8b3d49ab264aa26dbe8c6c1d1f1bebb955d73b"],"user_facing":false}
5
+ {"commits":["33dfbe42738b308dc01df908c8286b48eb97e12f"],"user_facing":false}
6
+ {"commits":["f2b61b9ca7eb86207a0631d01cb672c39dcb89a0"],"user_facing":false}
@@ -0,0 +1,6 @@
1
+ ## 0.10.0
2
+
3
+ ### Features
4
+
5
+ - **New feature.** `--dump-schema` omits fields matching defaults and includes a top-level `defaults` object documenting what missing fields mean.
6
+ - **New feature.** `@`-prefix for string flag values: `@path` reads from file, `@-` reads from stdin, `@@` escapes. 1 MB size limit, trailing whitespace stripped.
@@ -0,0 +1,9 @@
1
+ {"commits":["034d46ccdb83e2e6abdfb27d50553128e8b194da"],"user_facing":true,"description":"**New feature.** Public `config_file_path` property on App.","type":"feature"}
2
+ {"commits":["0eacbbb26047b737ac9ff5a05ccc25f381c6917f"],"user_facing":true,"description":"**Fix.** Config format validation errors now have parity between Python and Go.","type":"fix"}
3
+ {"commits":["a40e7421f7e51240f51c4a8ce9ed72e73af2dd94","3d0745ec3da5d124465ae62a6edda1ba9c586909","e1512089774da17d8e1b50b38af9e00f2b04f401","06ff387b1cf6e867e5cf926ad02c646f4020cd4d","f53fa79fb38d1d7ace07305c06030800fe8173bc"],"user_facing":false}
4
+ {"commits":["052392699e08e4edf48d688f244241c461d7eef7","4c64feea9c5efed933f46950a66985707d287c2c","a3058311994565626d720afb34f95f9230828500","2732196f195b787c8c1785c1d054e31d81be73f6","76e89079ca555173f31e16868a8f0e9da8dd86e9"],"user_facing":false}
5
+ {"commits":["81cfebb6de9674be9fafc0756c19d0dd8a6a29c1","b2317bcce29fdeabb0726e7db6865a917d596d91","0bc0c1f80c5252846a685954052690451676815d","905a378a3360abb1fd00fb009862a2d1875f388c"],"user_facing":false}
6
+ {"commits":["77050e3cdb3cdd396bf83fee87768342903157f7"],"user_facing":false}
7
+ {"commits":["2eb65bb7d36320cf1e398e42fa43a63e2d771042","9276de627df40c14d0a0f7f4cd6f934323d7ee47"],"user_facing":false}
8
+ {"commits":["7ccae37d21414a46c87167f726c2279e61db6b09"],"user_facing":false}
9
+ {"commits":["1179ab585f13a63254148ef67705df81ff07b99d"],"user_facing":false}
@@ -0,0 +1,9 @@
1
+ ## 0.9.1
2
+
3
+ ### Features
4
+
5
+ - **New feature.** Public `config_file_path` property on App.
6
+
7
+ ### Fixes
8
+
9
+ - **Fix.** Config format validation errors now have parity between Python and Go.
@@ -0,0 +1,3 @@
1
+ bump = "minor"
2
+ include = ["pypi", "npm"]
3
+ exclude = []
@@ -0,0 +1,3 @@
1
+ bump = "patch"
2
+ include = ["pypi", "npm"]
3
+ exclude = []
@@ -0,0 +1 @@
1
+ 0.42.0
@@ -2,6 +2,23 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## 0.10.0
6
+
7
+ ### Features
8
+
9
+ - **New feature.** `--dump-schema` omits fields matching defaults and includes a top-level `defaults` object documenting what missing fields mean.
10
+ - **New feature.** `@`-prefix for string flag values: `@path` reads from file, `@-` reads from stdin, `@@` escapes. 1 MB size limit, trailing whitespace stripped.
11
+
12
+ ## 0.9.1
13
+
14
+ ### Features
15
+
16
+ - **New feature.** Public `config_file_path` property on App.
17
+
18
+ ### Fixes
19
+
20
+ - **Fix.** Config format validation errors now have parity between Python and Go.
21
+
5
22
  ## 0.9.0
6
23
 
7
24
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.9.0
3
+ Version: 0.10.0
4
4
  Summary: A strict, zero-dependency CLI framework for Python
5
5
  Project-URL: Homepage, https://github.com/smm-h/strictcli
6
6
  Project-URL: Repository, https://github.com/smm-h/strictcli
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "strictcli",
9
- "version": "0.9.0",
9
+ "version": "0.10.0",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "A strict, zero-dependency CLI framework for Python (npm wrapper)",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.9.0"
7
+ version = "0.10.0"
8
8
  description = "A strict, zero-dependency CLI framework for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.9.0"
5
+ __version__ = "0.10.0"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "Tag", "MutexGroup", "CoRequired", "Requires",
@@ -316,6 +316,50 @@ def _float_parse_error(
316
316
  return _ParseError(f"--{flag_name}: expected float, got {raw!r}{suffix}")
317
317
 
318
318
 
319
+ _AT_PREFIX_MAX_SIZE = 1024 * 1024 # 1 MB
320
+
321
+
322
+ def _resolve_at_prefix(
323
+ flag_name: str, raw: str, stdin_consumed_by: str | None,
324
+ ) -> tuple[str, str | None]:
325
+ """Resolve @-prefix for string flag values.
326
+
327
+ Returns (resolved_value, updated_stdin_consumed_by).
328
+ """
329
+ if not raw.startswith("@"):
330
+ return raw, stdin_consumed_by
331
+ if raw.startswith("@@"):
332
+ return raw[1:], stdin_consumed_by
333
+ if raw == "@-":
334
+ if stdin_consumed_by is not None:
335
+ raise _ParseError(
336
+ f"--{flag_name}: stdin (@-) can only be used once per invocation"
337
+ )
338
+ try:
339
+ data = sys.stdin.read(_AT_PREFIX_MAX_SIZE + 1)
340
+ if len(data) > _AT_PREFIX_MAX_SIZE:
341
+ raise _ParseError(f"--{flag_name}: file exceeds 1 MB limit")
342
+ return data.rstrip(), flag_name
343
+ except _ParseError:
344
+ raise
345
+ except Exception:
346
+ raise _ParseError(f"--{flag_name}: cannot read stdin")
347
+ # @path -- read file
348
+ path = raw[1:]
349
+ if not os.path.exists(path):
350
+ raise _ParseError(f"--{flag_name}: file not found: {path}")
351
+ try:
352
+ with open(path, "r") as f:
353
+ data = f.read(_AT_PREFIX_MAX_SIZE + 1)
354
+ if len(data) > _AT_PREFIX_MAX_SIZE:
355
+ raise _ParseError(f"--{flag_name}: file exceeds 1 MB limit")
356
+ return data.rstrip(), stdin_consumed_by
357
+ except _ParseError:
358
+ raise
359
+ except Exception:
360
+ raise _ParseError(f"--{flag_name}: cannot read file: {path}")
361
+
362
+
319
363
  def _require_non_empty_str(value: str, field_name: str, class_name: str) -> None:
320
364
  if not isinstance(value, str) or not value.strip():
321
365
  raise ValueError(f"{class_name}.{field_name} must be a non-empty string")
@@ -669,6 +713,11 @@ class App:
669
713
  self._check_defs = {}
670
714
  self._checks_enabled = False
671
715
 
716
+ @property
717
+ def config_file_path(self) -> str:
718
+ """Return the resolved config file path for this app."""
719
+ return _config_path(self.name, override=self.config_path, config_format=self.config_format)
720
+
672
721
  def check(self, name: str):
673
722
  """Decorator to register a check implementation."""
674
723
  def decorator(fn):
@@ -1019,6 +1068,7 @@ class App:
1019
1068
  raise _DumpSchemaRequested()
1020
1069
 
1021
1070
  # Step 1.5: parse global flags before command routing
1071
+ self._stdin_consumed_by: str | None = None
1022
1072
  global_values, remaining = self._parse_global_flags(argv)
1023
1073
  self._last_global_values = global_values
1024
1074
 
@@ -1087,9 +1137,13 @@ class App:
1087
1137
  return cmd, rest
1088
1138
 
1089
1139
  # Step 3: parse remaining tokens for the resolved command
1140
+ # Pass stdin_consumed_by as a mutable single-element list so
1141
+ # _parse_command can update the shared state.
1142
+ stdin_state: list[str | None] = [self._stdin_consumed_by]
1090
1143
  try:
1091
1144
  cmd, kwargs, post_global = _parse_command(
1092
1145
  cmd, rest, self._global_flags, config_data=self._config_data,
1146
+ stdin_consumed_by=stdin_state,
1093
1147
  )
1094
1148
  except _ParseError as e:
1095
1149
  prefix_parts = [self.name] + path + [cmd.name]
@@ -1176,7 +1230,10 @@ class App:
1176
1230
  except ValueError as e:
1177
1231
  raise _float_parse_error(f.name, value_part, e)
1178
1232
  else:
1179
- _store_value(f, value_part)
1233
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1234
+ f.name, value_part, self._stdin_consumed_by,
1235
+ )
1236
+ _store_value(f, resolved)
1180
1237
  i += 1
1181
1238
  continue
1182
1239
  elif flag_part in negation_lookup:
@@ -1217,7 +1274,10 @@ class App:
1217
1274
  except ValueError as e:
1218
1275
  raise _float_parse_error(f.name, raw, e)
1219
1276
  else:
1220
- _store_value(f, raw)
1277
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1278
+ f.name, raw, self._stdin_consumed_by,
1279
+ )
1280
+ _store_value(f, resolved)
1221
1281
  i += 2
1222
1282
  else:
1223
1283
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1245,7 +1305,10 @@ class App:
1245
1305
  except ValueError as e:
1246
1306
  raise _float_parse_error(f.name, raw, e)
1247
1307
  else:
1248
- _store_value(f, raw)
1308
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1309
+ f.name, raw, self._stdin_consumed_by,
1310
+ )
1311
+ _store_value(f, resolved)
1249
1312
  i += 2
1250
1313
  else:
1251
1314
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1292,7 +1355,10 @@ class App:
1292
1355
  raise _float_parse_error(f.name, env_val, e, env=f.env)
1293
1356
  cli_set[f.name] = [coerced] if f.repeatable else coerced
1294
1357
  else:
1295
- cli_set[f.name] = [env_val] if f.repeatable else env_val
1358
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1359
+ f.name, env_val, self._stdin_consumed_by,
1360
+ )
1361
+ cli_set[f.name] = [resolved] if f.repeatable else resolved
1296
1362
 
1297
1363
  # Resolve config values for global flags not set by CLI or env
1298
1364
  if self._config_data:
@@ -1466,12 +1532,18 @@ def _parse_command(
1466
1532
  tokens: list[str],
1467
1533
  global_flags: list[Flag] | None = None,
1468
1534
  config_data: dict | None = None,
1535
+ stdin_consumed_by: list[str | None] | None = None,
1469
1536
  ) -> tuple[Command, dict[str, object], dict[str, object]]:
1470
1537
  """Parse tokens against a resolved command's flags and args.
1471
1538
 
1472
1539
  Returns (cmd, kwargs, global_cli_set) where global_cli_set contains
1473
1540
  any global flag values parsed from tokens appearing after the command name.
1541
+
1542
+ stdin_consumed_by is a mutable single-element list tracking which flag
1543
+ has already consumed stdin via @-. Updated in-place.
1474
1544
  """
1545
+ if stdin_consumed_by is None:
1546
+ stdin_consumed_by = [None]
1475
1547
 
1476
1548
  # Build flag lookup dicts
1477
1549
  long_lookup: dict[str, Flag] = {} # --flag-name -> Flag
@@ -1549,7 +1621,10 @@ def _parse_command(
1549
1621
  except ValueError as e:
1550
1622
  raise _float_parse_error(f.name, value_part, e)
1551
1623
  else:
1552
- _store_value(f, value_part)
1624
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1625
+ f.name, value_part, stdin_consumed_by[0],
1626
+ )
1627
+ _store_value(f, resolved)
1553
1628
  elif flag_part in negation_lookup:
1554
1629
  raise _ParseError(
1555
1630
  f"flag '{flag_part}' is a boolean negation and does not take a value"
@@ -1588,7 +1663,10 @@ def _parse_command(
1588
1663
  except ValueError as e:
1589
1664
  raise _float_parse_error(f.name, raw, e)
1590
1665
  else:
1591
- _store_value(f, raw)
1666
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1667
+ f.name, raw, stdin_consumed_by[0],
1668
+ )
1669
+ _store_value(f, resolved)
1592
1670
  i += 2
1593
1671
  else:
1594
1672
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1618,7 +1696,10 @@ def _parse_command(
1618
1696
  except ValueError as e:
1619
1697
  raise _float_parse_error(f.name, raw, e)
1620
1698
  else:
1621
- _store_value(f, raw)
1699
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1700
+ f.name, raw, stdin_consumed_by[0],
1701
+ )
1702
+ _store_value(f, resolved)
1622
1703
  i += 2
1623
1704
  else:
1624
1705
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1663,7 +1744,10 @@ def _parse_command(
1663
1744
  raise _float_parse_error(f.name, env_val, e, env=f.env)
1664
1745
  cli_set[f.name] = [coerced] if f.repeatable else coerced
1665
1746
  else:
1666
- cli_set[f.name] = [env_val] if f.repeatable else env_val
1747
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1748
+ f.name, env_val, stdin_consumed_by[0],
1749
+ )
1750
+ cli_set[f.name] = [resolved] if f.repeatable else resolved
1667
1751
 
1668
1752
  # Step 4.2: resolve config values for flags not set by CLI or env
1669
1753
  if config_data:
@@ -2865,66 +2949,158 @@ _TYPE_NAMES = {str: "str", bool: "bool", int: "int", float: "float"}
2865
2949
 
2866
2950
 
2867
2951
  def _serialize_flag(f: Flag) -> dict:
2868
- """Serialize a Flag to a JSON-serializable dict."""
2869
- return {
2952
+ """Serialize a Flag to a JSON-serializable dict.
2953
+
2954
+ Identity fields (name, type, help) are always included.
2955
+ Other fields are omitted when they match the schema defaults.
2956
+ """
2957
+ d: dict = {
2870
2958
  "name": f.name,
2871
2959
  "type": _TYPE_NAMES[f.type],
2872
2960
  "help": f.help,
2873
- "short": f.short,
2874
- "default": f.default,
2875
- "env": f.env,
2876
- "choices": f.choices,
2877
- "repeatable": f.repeatable,
2878
- "negatable": f.negatable if f.type is bool else None,
2879
- "hidden": False,
2880
2961
  }
2962
+ if f.short is not None:
2963
+ d["short"] = f.short
2964
+ if f.default is not None:
2965
+ d["default"] = f.default
2966
+ if f.env is not None:
2967
+ d["env"] = f.env
2968
+ if f.choices is not None:
2969
+ d["choices"] = f.choices
2970
+ if f.repeatable:
2971
+ d["repeatable"] = f.repeatable
2972
+ negatable = f.negatable if f.type is bool else None
2973
+ if negatable is not None:
2974
+ d["negatable"] = negatable
2975
+ # hidden is currently always False, so always omitted
2976
+ return d
2881
2977
 
2882
2978
 
2883
2979
  def _serialize_arg(a: Arg) -> dict:
2884
- """Serialize an Arg to a JSON-serializable dict."""
2885
- return {
2980
+ """Serialize an Arg to a JSON-serializable dict.
2981
+
2982
+ Identity fields (name, help) are always included.
2983
+ Other fields are omitted when they match the schema defaults.
2984
+ """
2985
+ d: dict = {
2886
2986
  "name": a.name,
2887
2987
  "help": a.help,
2888
- "required": a.required,
2889
- "variadic": a.variadic,
2890
2988
  }
2989
+ if not a.required:
2990
+ d["required"] = a.required
2991
+ if a.variadic:
2992
+ d["variadic"] = a.variadic
2993
+ return d
2891
2994
 
2892
2995
 
2893
2996
  def _serialize_command(cmd: Command) -> dict:
2894
- """Serialize a Command to a JSON-serializable dict."""
2895
- return {
2997
+ """Serialize a Command to a JSON-serializable dict.
2998
+
2999
+ Identity fields (name, help) are always included.
3000
+ Other fields are omitted when they match the schema defaults.
3001
+ """
3002
+ d: dict = {
2896
3003
  "name": cmd.name,
2897
3004
  "help": cmd.help,
2898
- "flags": [_serialize_flag(f) for f in cmd.flags],
2899
- "args": [_serialize_arg(a) for a in cmd.args],
2900
- "passthrough": cmd.passthrough is not None,
2901
3005
  }
3006
+ if cmd.passthrough is not None:
3007
+ d["passthrough"] = True
3008
+ flags = [_serialize_flag(f) for f in cmd.flags]
3009
+ if flags:
3010
+ d["flags"] = flags
3011
+ args = [_serialize_arg(a) for a in cmd.args]
3012
+ if args:
3013
+ d["args"] = args
3014
+ return d
2902
3015
 
2903
3016
 
2904
3017
  def _serialize_group(group: Group) -> dict:
2905
- """Serialize a Group to a JSON-serializable dict (recursive)."""
2906
- return {
3018
+ """Serialize a Group to a JSON-serializable dict (recursive).
3019
+
3020
+ Identity fields (name, help) are always included.
3021
+ Other fields are omitted when they match the schema defaults.
3022
+ """
3023
+ d: dict = {
2907
3024
  "name": group.name,
2908
3025
  "help": group.help,
2909
- "commands": {name: _serialize_command(cmd) for name, cmd in group.commands.items()},
2910
- "groups": {name: _serialize_group(g) for name, g in group._groups.items()},
2911
- "deprecated": {name: dep.message for name, dep in group.deprecated.items()},
3026
+ }
3027
+ commands = {name: _serialize_command(cmd) for name, cmd in group.commands.items()}
3028
+ if commands:
3029
+ d["commands"] = commands
3030
+ groups = {name: _serialize_group(g) for name, g in group._groups.items()}
3031
+ if groups:
3032
+ d["groups"] = groups
3033
+ deprecated = {name: dep.message for name, dep in group.deprecated.items()}
3034
+ if deprecated:
3035
+ d["deprecated"] = deprecated
3036
+ return d
3037
+
3038
+
3039
+ def _build_schema_defaults() -> dict:
3040
+ """Return the defaults object documenting what 'missing' means in the schema."""
3041
+ return {
3042
+ "app": {
3043
+ "env_prefix": None,
3044
+ "config": False,
3045
+ "global_flags": [],
3046
+ "commands": {},
3047
+ "groups": {},
3048
+ "deprecated": {},
3049
+ },
3050
+ "flag": {
3051
+ "short": None,
3052
+ "default": None,
3053
+ "env": None,
3054
+ "choices": None,
3055
+ "repeatable": False,
3056
+ "negatable": None,
3057
+ "hidden": False,
3058
+ },
3059
+ "arg": {
3060
+ "required": True,
3061
+ "variadic": False,
3062
+ },
3063
+ "command": {
3064
+ "passthrough": False,
3065
+ "flags": [],
3066
+ "args": [],
3067
+ },
3068
+ "group": {
3069
+ "commands": {},
3070
+ "groups": {},
3071
+ "deprecated": {},
3072
+ },
2912
3073
  }
2913
3074
 
2914
3075
 
2915
3076
  def _dump_schema(app: App) -> dict:
2916
- """Produce a JSON-serializable dict representing the app's command tree."""
2917
- schema = {
3077
+ """Produce a JSON-serializable dict representing the app's command tree.
3078
+
3079
+ Fields whose values match the schema defaults are omitted.
3080
+ The top-level ``defaults`` key documents what each missing field means.
3081
+ """
3082
+ schema: dict = {
3083
+ "defaults": _build_schema_defaults(),
2918
3084
  "name": app.name,
2919
3085
  "version": app.version,
2920
3086
  "help": app.help,
2921
- "env_prefix": app.env_prefix,
2922
- "config": app.config,
2923
- "global_flags": [_serialize_flag(f) for f in app._global_flags],
2924
- "commands": {name: _serialize_command(cmd) for name, cmd in app._commands.items()},
2925
- "groups": {name: _serialize_group(grp) for name, grp in app._groups.items()},
2926
- "deprecated": {name: dep.message for name, dep in app._deprecated.items()},
2927
3087
  }
3088
+ if app.env_prefix is not None:
3089
+ schema["env_prefix"] = app.env_prefix
3090
+ if app.config:
3091
+ schema["config"] = app.config
3092
+ global_flags = [_serialize_flag(f) for f in app._global_flags]
3093
+ if global_flags:
3094
+ schema["global_flags"] = global_flags
3095
+ commands = {name: _serialize_command(cmd) for name, cmd in app._commands.items()}
3096
+ if commands:
3097
+ schema["commands"] = commands
3098
+ groups = {name: _serialize_group(grp) for name, grp in app._groups.items()}
3099
+ if groups:
3100
+ schema["groups"] = groups
3101
+ deprecated = {name: dep.message for name, dep in app._deprecated.items()}
3102
+ if deprecated:
3103
+ schema["deprecated"] = deprecated
2928
3104
  if app._checks_enabled:
2929
3105
  schema["checks"] = {
2930
3106
  name: {