strictcli 0.41.0__tar.gz → 0.41.2__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 (109) hide show
  1. {strictcli-0.41.0 → strictcli-0.41.2}/PKG-INFO +10 -5
  2. {strictcli-0.41.0 → strictcli-0.41.2}/README.md +1 -1
  3. {strictcli-0.41.0 → strictcli-0.41.2}/pyproject.toml +18 -4
  4. {strictcli-0.41.0 → strictcli-0.41.2}/strictcli/__init__.py +114 -21
  5. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_coverage.py +36 -0
  6. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_member_spelling.py +238 -0
  7. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_selectors.py +26 -0
  8. {strictcli-0.41.0 → strictcli-0.41.2}/uv.lock +1 -1
  9. {strictcli-0.41.0 → strictcli-0.41.2}/.claude/settings.json +0 -0
  10. {strictcli-0.41.0 → strictcli-0.41.2}/.github/workflows/ci.yml +0 -0
  11. {strictcli-0.41.0 → strictcli-0.41.2}/.github/workflows/publish.yml +0 -0
  12. {strictcli-0.41.0 → strictcli-0.41.2}/.gitignore +0 -0
  13. {strictcli-0.41.0 → strictcli-0.41.2}/.rlsbl/config.json +0 -0
  14. {strictcli-0.41.0 → strictcli-0.41.2}/.rlsbl/lint/python.toml +0 -0
  15. {strictcli-0.41.0 → strictcli-0.41.2}/.rlsbl/managed-files.json +0 -0
  16. {strictcli-0.41.0 → strictcli-0.41.2}/CLAUDE.md +0 -0
  17. {strictcli-0.41.0 → strictcli-0.41.2}/LICENSE +0 -0
  18. {strictcli-0.41.0 → strictcli-0.41.2}/scripts/add_effect_classification.py +0 -0
  19. {strictcli-0.41.0 → strictcli-0.41.2}/scripts/add_forwarding_declaration.py +0 -0
  20. {strictcli-0.41.0 → strictcli-0.41.2}/scripts/migrate_presence.py +0 -0
  21. {strictcli-0.41.0 → strictcli-0.41.2}/strictcli/py.typed +0 -0
  22. {strictcli-0.41.0 → strictcli-0.41.2}/tests/conftest.py +0 -0
  23. {strictcli-0.41.0 → strictcli-0.41.2}/tests/flagship_app.py +0 -0
  24. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_arg_decorator_order.py +0 -0
  25. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_arg_default.py +0 -0
  26. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_arg_default_validation.py +0 -0
  27. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_argv_value_order.py +0 -0
  28. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_at_prefix.py +0 -0
  29. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_auto_version.py +0 -0
  30. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_call.py +0 -0
  31. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_command.py +0 -0
  32. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_discovery.py +0 -0
  33. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_provider.py +0 -0
  34. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_public_api.py +0 -0
  35. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_runner.py +0 -0
  36. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_schema.py +0 -0
  37. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_types.py +0 -0
  38. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_choice_records.py +0 -0
  39. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_choices.py +0 -0
  40. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_choices_none.py +0 -0
  41. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_claimed_rendering.py +0 -0
  42. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_classification.py +0 -0
  43. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_command_help_suggestion.py +0 -0
  44. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_command_tags.py +0 -0
  45. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_compound_types.py +0 -0
  46. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_config.py +0 -0
  47. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_config_fields.py +0 -0
  48. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_config_file_path.py +0 -0
  49. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_config_set_bugs.py +0 -0
  50. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_confirm.py +0 -0
  51. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_connection_env.py +0 -0
  52. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_constraints.py +0 -0
  53. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_context.py +0 -0
  54. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_deep_nesting.py +0 -0
  55. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_deprecated.py +0 -0
  56. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_dry_run_unsupported.py +0 -0
  57. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_dump_schema.py +0 -0
  58. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_e2e.py +0 -0
  59. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_effects.py +0 -0
  60. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_effects_bypass_check.py +0 -0
  61. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_env.py +0 -0
  62. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_exit_codes.py +0 -0
  63. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_flag_sets.py +0 -0
  64. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_flagship_preview.py +0 -0
  65. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_flat_pre_typed.py +0 -0
  66. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_float_format.py +0 -0
  67. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_float_type.py +0 -0
  68. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_float_vectors.py +0 -0
  69. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_global_flag_conflict_position.py +0 -0
  70. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_global_flags.py +0 -0
  71. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_guard_v2.py +0 -0
  72. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_help.py +0 -0
  73. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_hermetic.py +0 -0
  74. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_infra_env.py +0 -0
  75. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_int_type.py +0 -0
  76. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_invoke.py +0 -0
  77. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_keyword_flags.py +0 -0
  78. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_machine_mode.py +0 -0
  79. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_mcp.py +0 -0
  80. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_nesting.py +0 -0
  81. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_owns_stdout.py +0 -0
  82. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_parser.py +0 -0
  83. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_passthrough.py +0 -0
  84. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_payload_schema.py +0 -0
  85. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_presence.py +0 -0
  86. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_provenance.py +0 -0
  87. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_provenance_phase2.py +0 -0
  88. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_record_pre_typed.py +0 -0
  89. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_registration.py +0 -0
  90. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_repeatable.py +0 -0
  91. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_reserved_global_flags.py +0 -0
  92. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_reserved_quartet.py +0 -0
  93. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_tagdsl.py +0 -0
  94. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_toml_loading.py +0 -0
  95. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_tool_export.py +0 -0
  96. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_trace_store.py +0 -0
  97. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_typed_args.py +0 -0
  98. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_ulid_vectors.py +0 -0
  99. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_unique.py +0 -0
  100. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_update.py +0 -0
  101. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_utilities.py +0 -0
  102. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_validate.py +0 -0
  103. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_value_sweep_order.py +0 -0
  104. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_variadic.py +0 -0
  105. {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_visibility.py +0 -0
  106. {strictcli-0.41.0 → strictcli-0.41.2}/todo/.defer/deferred.md +0 -0
  107. {strictcli-0.41.0 → strictcli-0.41.2}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  108. {strictcli-0.41.0 → strictcli-0.41.2}/todo/.done/original-idea.md +0 -0
  109. {strictcli-0.41.0 → strictcli-0.41.2}/todo/.done/public-check-runner-api.md +0 -0
@@ -1,16 +1,21 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: strictcli
3
- Version: 0.41.0
4
- Summary: A strict CLI framework for Python
5
- Project-URL: Homepage, https://github.com/smm-h/strictcli
3
+ Version: 0.41.2
4
+ Summary: A strict CLI framework for Python, Go and TypeScript that makes you declare every command, flag, argument and help string, and errors at registration time on anything left unstated (Python implementation)
5
+ Project-URL: Homepage, https://smmh.dev/strictcli/
6
+ Project-URL: Documentation, https://smmh.dev/strictcli/
6
7
  Project-URL: Repository, https://github.com/smm-h/strictcli
8
+ Project-URL: Issues, https://github.com/smm-h/strictcli/issues
9
+ Project-URL: Changelog, https://github.com/smm-h/strictcli/blob/main/CHANGELOG.md
7
10
  Author-email: "S. M. Hosseini" <m.hosseini@veliu.com>
8
11
  License-Expression: MIT
9
12
  License-File: LICENSE
10
- Keywords: argparse,cli,command-line,framework,rlsbl,strictcli
13
+ Keywords: argparse,argument-parser,cli,cli-framework,command-line,framework,rlsbl,strictcli
11
14
  Classifier: Development Status :: 3 - Alpha
15
+ Classifier: Environment :: Console
12
16
  Classifier: Intended Audience :: Developers
13
17
  Classifier: License :: OSI Approved :: MIT License
18
+ Classifier: Operating System :: OS Independent
14
19
  Classifier: Programming Language :: Python :: 3
15
20
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
16
21
  Requires-Python: >=3.11
@@ -19,7 +24,7 @@ Description-Content-Type: text/markdown
19
24
 
20
25
  # strictcli
21
26
 
22
- A strict CLI framework for Python.
27
+ A strict CLI framework for Python, Go and TypeScript that makes you declare every command, flag, argument and help string, and errors at registration time on anything left unstated (Python implementation).
23
28
 
24
29
  strictcli makes you declare everything -- every command, flag, argument, and environment variable must have help text or the framework errors at registration time. Four types only: `str`, `bool`, `int`, `float`. No magic type inference, no implicit defaults.
25
30
 
@@ -1,6 +1,6 @@
1
1
  # strictcli
2
2
 
3
- A strict CLI framework for Python.
3
+ A strict CLI framework for Python, Go and TypeScript that makes you declare every command, flag, argument and help string, and errors at registration time on anything left unstated (Python implementation).
4
4
 
5
5
  strictcli makes you declare everything -- every command, flag, argument, and environment variable must have help text or the framework errors at registration time. Four types only: `str`, `bool`, `int`, `float`. No magic type inference, no implicit defaults.
6
6
 
@@ -4,17 +4,28 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.41.0"
8
- description = "A strict CLI framework for Python"
7
+ version = "0.41.2"
8
+ description = "A strict CLI framework for Python, Go and TypeScript that makes you declare every command, flag, argument and help string, and errors at registration time on anything left unstated (Python implementation)"
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  requires-python = ">=3.11"
12
12
  authors = [{ name = "S. M. Hosseini", email = "m.hosseini@veliu.com" }]
13
- keywords = ["strictcli", "cli", "argparse", "command-line", "framework", "rlsbl"]
13
+ keywords = [
14
+ "strictcli",
15
+ "cli",
16
+ "cli-framework",
17
+ "command-line",
18
+ "argument-parser",
19
+ "argparse",
20
+ "framework",
21
+ "rlsbl",
22
+ ]
14
23
  classifiers = [
15
24
  "Development Status :: 3 - Alpha",
25
+ "Environment :: Console",
16
26
  "Intended Audience :: Developers",
17
27
  "License :: OSI Approved :: MIT License",
28
+ "Operating System :: OS Independent",
18
29
  "Programming Language :: Python :: 3",
19
30
  "Topic :: Software Development :: Libraries :: Python Modules",
20
31
  ]
@@ -29,5 +40,8 @@ dev = ["pytest", "allpairspy", "jsonschema"]
29
40
  testpaths = ["tests"]
30
41
 
31
42
  [project.urls]
32
- Homepage = "https://github.com/smm-h/strictcli"
43
+ Homepage = "https://smmh.dev/strictcli/"
44
+ Documentation = "https://smmh.dev/strictcli/"
33
45
  Repository = "https://github.com/smm-h/strictcli"
46
+ Issues = "https://github.com/smm-h/strictcli/issues"
47
+ Changelog = "https://github.com/smm-h/strictcli/blob/main/CHANGELOG.md"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.41.0"
5
+ __version__ = "0.41.2"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet",
@@ -4606,6 +4606,10 @@ _RECORD_SOURCES_ATTR = "__strictcli_sources__"
4606
4606
  _RECORD_SPELLING = "Choice(<value>, help=...)"
4607
4607
  _SELECTOR_SPELLING = "choice_flag(...)"
4608
4608
  _MEMBER_SELECTOR_SPELLING = 'choice_flag(..., elect_by="member-flags")'
4609
+ # The payload-carrying member's own declaration, which is where its short goes:
4610
+ # `member_value(...)` IS the electing flag's declaration in this language, the
4611
+ # way `MemberChoice`'s first argument is in Go (§24.12).
4612
+ _MEMBER_PAYLOAD_SHORT_SPELLING = "member_value(short=...)"
4609
4613
 
4610
4614
 
4611
4615
  @dataclass(frozen=True)
@@ -5129,6 +5133,11 @@ class _ChoiceDecl:
5129
5133
  help: str
5130
5134
  cls: type
5131
5135
  localns: dict
5136
+ # The short of a PAYLOAD-LESS member's electing flag (§24.4, §24.12). A
5137
+ # payload-carrying member declares its short on `member_value(short=...)`
5138
+ # instead -- that field IS its electing flag's declaration -- and a
5139
+ # token-spelled choice has no flag of its own to carry one at all.
5140
+ short: str | None = None
5132
5141
 
5133
5142
 
5134
5143
  @dataclass(frozen=True)
@@ -5140,6 +5149,11 @@ class _ChoiceSpec:
5140
5149
  cls: type
5141
5150
  members: tuple[object, ...] # Flag | _Selector, in declaration order
5142
5151
  payload: Flag | None = None # member spelling's `value` field, if any
5152
+ # The member flag's short, whichever spelling declared it: the one place
5153
+ # every reader of a member's short looks (the cross-scope claim table, the
5154
+ # `-x` token scan, the help line). None for a token-spelled choice, which
5155
+ # is never a token of its own.
5156
+ short: str | None = None
5143
5157
 
5144
5158
 
5145
5159
  @dataclass(frozen=True)
@@ -5228,6 +5242,33 @@ def _raise_token_choice_carries_payload(sel: str, c: str):
5228
5242
  )
5229
5243
 
5230
5244
 
5245
+ def _raise_token_choice_carries_short(sel: str, c: str):
5246
+ """Authored: a short on a token-spelled choice has no token to name.
5247
+
5248
+ Under token spelling the choice is named by the selector's own value, and
5249
+ a value has no short form -- only a member-spelled choice puts a flag of
5250
+ its own on the command line (§24.4).
5251
+ """
5252
+ raise ValueError(
5253
+ f'Choice "{c}" of "{sel}": a token-spelled choice cannot carry a '
5254
+ f"short: the token names the choice, and only a member-spelled choice "
5255
+ f"has a flag of its own to carry one"
5256
+ )
5257
+
5258
+
5259
+ def _raise_member_short_on_payload_choice(sel: str, c: str):
5260
+ """Authored: one member, one place to declare its short (§24.4, §24.12).
5261
+
5262
+ A payload-carrying member's electing flag IS its payload declaration, so
5263
+ the short belongs there; carrying it on the choice as well would be two
5264
+ declarations that must agree.
5265
+ """
5266
+ raise ValueError(
5267
+ f'Choice "{c}" of "{sel}": a payload-carrying member declares its '
5268
+ f"short on its payload: {_MEMBER_PAYLOAD_SHORT_SPELLING}"
5269
+ )
5270
+
5271
+
5231
5272
  def _raise_scoped_name_choice_reserved(c: str, sel: str):
5232
5273
  raise ValueError(
5233
5274
  f'Choice "{c}" of "{sel}": flag name \'choice\' is reserved by the '
@@ -5579,7 +5620,9 @@ def _raise_selector_default_not_instance(sel: str, got: str):
5579
5620
  # --- the declaration surface -----------------------------------------------
5580
5621
 
5581
5622
 
5582
- def choice(name: str, *, help: str): # noqa: A002 - mirrors the framework keyword
5623
+ def choice( # noqa: A002 - mirrors the framework keyword
5624
+ name: str, *, help: str, short: str | None = None,
5625
+ ):
5583
5626
  """Class decorator: declare one choice of a selector, and its scope.
5584
5627
 
5585
5628
  The decorated class becomes a **frozen, keyword-only dataclass** -- it is
@@ -5588,6 +5631,12 @@ def choice(name: str, *, help: str): # noqa: A002 - mirrors the framework keywo
5588
5631
  out of ``dataclasses`` (§24.12). Its fields ARE the scope's flags, declared
5589
5632
  with :func:`sub_flag`, :func:`sub_choice_flag` or :func:`member_value`.
5590
5633
 
5634
+ ``short`` declares the short form of a **payload-less member's** electing
5635
+ flag: such a choice has no ``member_value`` field, so the decorator is the
5636
+ only place its one token is declared. A payload-carrying member declares
5637
+ its short on :func:`member_value` instead, and a token-spelled choice
5638
+ cannot carry one at all -- both are registration errors (§24.4).
5639
+
5591
5640
  Validation of the scope is deferred to the selector that claims this class,
5592
5641
  because every message a scope raises names both the choice and the selector.
5593
5642
  """
@@ -5605,7 +5654,7 @@ def choice(name: str, *, help: str): # noqa: A002 - mirrors the framework keywo
5605
5654
  ))
5606
5655
  dc = dataclasses.dataclass(frozen=True, kw_only=True)(cls)
5607
5656
  setattr(dc, _CHOICE_SPEC_ATTR, _ChoiceDecl(
5608
- name=name, help=help, cls=dc, localns=localns,
5657
+ name=name, help=help, cls=dc, localns=localns, short=short,
5609
5658
  ))
5610
5659
  return dc
5611
5660
 
@@ -5716,14 +5765,21 @@ def _scope_field(payload: dict, default: object):
5716
5765
  return field(default=default, metadata=meta)
5717
5766
 
5718
5767
 
5719
- def member_value(*, help: str): # noqa: A002
5768
+ def member_value(*, help: str, short: str | None = None): # noqa: A002
5720
5769
  """Declare a member-spelled choice's own payload (§24.4, §24.12).
5721
5770
 
5722
5771
  A payload is exactly one value, delivered under the reserved name ``value``,
5723
5772
  and only under member spelling. It takes no presence keyword, because
5724
5773
  electing the member supplies it.
5774
+
5775
+ ``short`` is the electing flag's short form (``-r X`` for ``--role X``):
5776
+ this field IS that flag's declaration, exactly as ``MemberChoice``'s first
5777
+ argument is in Go. It is claimed across every simultaneously live scope
5778
+ like any other short, and it renders beside the member on its help line.
5725
5779
  """
5726
- return field(metadata={_SCOPE_FIELD_KEY: {"kind": "payload", "help": help}})
5780
+ return field(metadata={_SCOPE_FIELD_KEY: {
5781
+ "kind": "payload", "help": help, "short": short,
5782
+ }})
5727
5783
 
5728
5784
 
5729
5785
  def choice_flag(
@@ -6053,7 +6109,7 @@ def _build_choice_spec(
6053
6109
  )
6054
6110
  payload = Flag(
6055
6111
  name=decl.name, type=hints[f.name], help=meta["help"],
6056
- presence=_PRESENCE_REQUIRED,
6112
+ presence=_PRESENCE_REQUIRED, short=meta["short"],
6057
6113
  )
6058
6114
  continue
6059
6115
  # The two reserved names, checked before anything else looks at the
@@ -6099,9 +6155,20 @@ def _build_choice_spec(
6099
6155
  )
6100
6156
  members.append(nested)
6101
6157
 
6158
+ # The member's short, resolved to the ONE slot every reader looks at. Which
6159
+ # declaration carries it follows the member's shape: a payload-carrying
6160
+ # member's electing flag is its `member_value(...)`, a payload-less one has
6161
+ # no such field and declares it on `@choice(...)` (§24.4, §24.12).
6162
+ if decl.short is not None:
6163
+ if elect_by != _ELECT_MEMBER_FLAGS:
6164
+ _raise_token_choice_carries_short(sel_name, decl.name)
6165
+ if payload is not None:
6166
+ _raise_member_short_on_payload_choice(sel_name, decl.name)
6167
+ short = decl.short if payload is None else payload.short
6168
+
6102
6169
  return _ChoiceSpec(
6103
6170
  name=decl.name, help=decl.help, cls=cls,
6104
- members=tuple(members), payload=payload,
6171
+ members=tuple(members), payload=payload, short=short,
6105
6172
  )
6106
6173
 
6107
6174
 
@@ -6238,6 +6305,22 @@ def _member_spelling_map(selectors: tuple[_Selector, ...]) -> dict[str, bool]:
6238
6305
  return out
6239
6306
 
6240
6307
 
6308
+ def _short_claim_path(site: _Site) -> tuple[tuple[str, str], ...]:
6309
+ """The scope a site's short is claimed IN.
6310
+
6311
+ For every ordinary declaration that is the site's own path. A member site
6312
+ is recorded at the path its selector sits on -- its NAME is a flag name
6313
+ command-wide (§24.7) -- but the token it puts on the command line exists
6314
+ only while that member is elected, so its short is claimed one segment
6315
+ deeper. Two sibling members therefore never collide; what refuses them is
6316
+ the election-token guard (§18.19 item 221).
6317
+ """
6318
+ if site.kind != "member":
6319
+ return site.path
6320
+ sel: _Selector = site.decl # type: ignore[assignment]
6321
+ return site.path + ((sel.name, site.choice.name),)
6322
+
6323
+
6241
6324
  def _paths_mutually_exclusive(
6242
6325
  a: tuple[tuple[str, str], ...], b: tuple[tuple[str, str], ...],
6243
6326
  ) -> bool:
@@ -6328,7 +6411,7 @@ def _validate_scoped_names(
6328
6411
  shorts: list[tuple[str, _Site]] = []
6329
6412
  for site in sites:
6330
6413
  if site.kind == "member":
6331
- short = site.choice.payload.short if site.choice.payload else None
6414
+ short = site.choice.short
6332
6415
  else:
6333
6416
  short = getattr(site.decl, "short", None)
6334
6417
  if short:
@@ -6346,7 +6429,9 @@ def _validate_scoped_names(
6346
6429
  (s1, a), (s2, b) = shorts[i], shorts[j]
6347
6430
  if s1 != s2 or a.name == b.name:
6348
6431
  continue
6349
- if _paths_mutually_exclusive(a.path, b.path):
6432
+ if _paths_mutually_exclusive(
6433
+ _short_claim_path(a), _short_claim_path(b),
6434
+ ):
6350
6435
  continue
6351
6436
  _raise_short_collides_across_scopes(cmd_name, s1, a.name, b.name)
6352
6437
 
@@ -8893,6 +8978,12 @@ class App:
8893
8978
  # recorder and the check provider use these absolute paths so that
8894
8979
  # tests which chdir still record into the repo, and a check evaluated
8895
8980
  # from a foreign cwd reads the app's own repo state.
8981
+ #
8982
+ # Only the PATHS are computed here. The directory itself is created
8983
+ # lazily by _record_coverage, immediately before the first shard
8984
+ # write: shards are written only on the test-harness paths (test()
8985
+ # and call()), so a plain CLI invocation must leave no .strictcli/
8986
+ # behind in whatever directory it was run from.
8896
8987
  self._coverage_dir = os.path.abspath(
8897
8988
  os.path.join(".strictcli", "coverage")
8898
8989
  )
@@ -8903,7 +8994,6 @@ class App:
8903
8994
  self._coverage_dir,
8904
8995
  f"{os.getpid()}.jsonl",
8905
8996
  )
8906
- os.makedirs(self._coverage_dir, exist_ok=True)
8907
8997
  self.register_check_provider(self._test_coverage_provider)
8908
8998
 
8909
8999
  def _validate_flag_infra_marker(self, f: Flag) -> None:
@@ -14659,7 +14749,10 @@ def _parse_command(
14659
14749
  scoped_occs.append(_Occ(name, inline, tok, alts, seq=idx))
14660
14750
  return idx + 1
14661
14751
  if idx + 1 >= len(tokens):
14662
- raise _ParseError(f"flag '--{name}' requires a value")
14752
+ # The token AS TYPED, which is what the root-scope path and both
14753
+ # sibling implementations report: a reader who typed `-r` is told
14754
+ # about `-r`, not about the long form it resolved to.
14755
+ raise _ParseError(f"flag '{tok}' requires a value")
14663
14756
  scoped_occs.append(_Occ(name, tokens[idx + 1], tok, alts, seq=idx))
14664
14757
  return idx + 2
14665
14758
 
@@ -15206,10 +15299,7 @@ def _build_and_validate_command(
15206
15299
  scoped_shorts: dict[str, tuple[str, ...]] = {}
15207
15300
  for site in site_list:
15208
15301
  if site.kind == "member":
15209
- short = (
15210
- site.choice.payload.short
15211
- if site.choice.payload is not None else None
15212
- )
15302
+ short = site.choice.short
15213
15303
  else:
15214
15304
  short = getattr(site.decl, "short", None)
15215
15305
  if not short:
@@ -15849,14 +15939,17 @@ def _build_selector_spec(sel: _Selector) -> str:
15849
15939
 
15850
15940
 
15851
15941
  def _build_member_spec(spec: _ChoiceSpec) -> str:
15852
- """The left-column spec for one member flag under member spelling."""
15942
+ """The left-column spec for one member flag under member spelling.
15943
+
15944
+ The short comes from the choice rather than from its payload: a
15945
+ payload-less member declares one too, on `@choice(short=...)`.
15946
+ """
15853
15947
  out = f"--{spec.name}"
15948
+ if spec.short:
15949
+ out += f", -{spec.short}"
15854
15950
  payload = spec.payload
15855
- if payload is not None:
15856
- if payload.short:
15857
- out += f", -{payload.short}"
15858
- if payload.type is not bool:
15859
- out += f" <{_TYPE_NAMES[payload.type]}>"
15951
+ if payload is not None and payload.type is not bool:
15952
+ out += f" <{_TYPE_NAMES[payload.type]}>"
15860
15953
  return out
15861
15954
 
15862
15955
 
@@ -206,6 +206,7 @@ class TestCoverageCheck:
206
206
  when NEITHER a manifest NOR any shards exist."""
207
207
  app = _make_app(tmp_path)
208
208
  manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
209
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
209
210
  manifest_path.write_text("[]\n")
210
211
 
211
212
  results, _, code = app.run_checks(
@@ -296,6 +297,7 @@ class TestManifestUnionVerdict:
296
297
  with no shard files (the machine never ran the suite)."""
297
298
  app = _make_app(tmp_path)
298
299
  manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
300
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
299
301
  manifest_path.write_text(
300
302
  json.dumps(["build", "deploy", "status"], indent=2) + "\n"
301
303
  )
@@ -312,6 +314,7 @@ class TestManifestUnionVerdict:
312
314
  (anchored manifest), not the foreign directory."""
313
315
  app = _make_app(tmp_path)
314
316
  manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
317
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
315
318
  manifest_path.write_text(
316
319
  json.dumps(["build", "deploy", "status"], indent=2) + "\n"
317
320
  )
@@ -330,6 +333,7 @@ class TestManifestUnionVerdict:
330
333
  """A run recording only a subset keeps prior commands covered (union)."""
331
334
  app = _make_app(tmp_path)
332
335
  manifest_path = tmp_path / ".strictcli" / "test-coverage.json"
336
+ manifest_path.parent.mkdir(parents=True, exist_ok=True)
333
337
  manifest_path.write_text(
334
338
  json.dumps(["build", "deploy", "status"], indent=2) + "\n"
335
339
  )
@@ -374,3 +378,35 @@ class TestCoverageDisabled:
374
378
 
375
379
  coverage_dir = tmp_path / ".strictcli" / "coverage"
376
380
  assert not coverage_dir.exists()
381
+
382
+
383
+ class TestCoverageDirectoryIsLazy:
384
+ def test_construction_leaves_no_directory(self, tmp_path):
385
+ """Constructing an app with test_coverage=True must not create
386
+ .strictcli/ -- a plain CLI invocation never records coverage, so it must
387
+ not plant an empty directory in whatever cwd it was run from."""
388
+ app = _make_app(tmp_path)
389
+ assert app is not None
390
+ assert not (tmp_path / ".strictcli").exists()
391
+
392
+ def test_recording_creates_the_directory(self, tmp_path):
393
+ """The recorder creates the coverage directory immediately before the
394
+ first shard write."""
395
+ app = _make_app(tmp_path)
396
+ app.test(["deploy"])
397
+
398
+ shard = tmp_path / ".strictcli" / "coverage" / f"{os.getpid()}.jsonl"
399
+ assert shard.is_file()
400
+
401
+ def test_check_skips_when_directory_absent(self, tmp_path):
402
+ """The provider reads a coverage root that does not exist without
403
+ raising -- it reports the subject-matter SKIP."""
404
+ app = _make_app(tmp_path)
405
+ assert not (tmp_path / ".strictcli").exists()
406
+
407
+ results, _, _code = app.run_checks(
408
+ SimpleCtx(project_root=tmp_path),
409
+ run_all=True,
410
+ )
411
+ cov_result = next(r for r in results if r.name == "cli-test-coverage")
412
+ assert cov_result.status == "skip"
@@ -686,3 +686,241 @@ def test_a_later_scope_violation_outranks_an_earlier_missing_payload():
686
686
  "flag '--strict' is only valid under '--one-tag', but "
687
687
  "'--all-tags' was elected"
688
688
  )
689
+
690
+
691
+ # ---------------------------------------------------------------------------
692
+ # A member's short (§24.4, §24.12)
693
+ #
694
+ # The member flag is the only token member spelling puts on the command line,
695
+ # so its short is an ordinary flag short: claimed across every simultaneously
696
+ # live scope, typed as `-x`, and rendered beside the member on its help line.
697
+ # Which declaration carries it follows the member's shape -- a payload-carrying
698
+ # member's electing flag IS its `member_value(...)`, and a payload-less one has
699
+ # no such field, so it declares the short on `@choice(...)`.
700
+ # ---------------------------------------------------------------------------
701
+
702
+
703
+ def _short_app():
704
+ @choice("role", help="one role")
705
+ class Role:
706
+ value: str = member_value(help="the role name", short="r")
707
+
708
+ @choice("cont", help="continue the previous session", short="c")
709
+ class Cont:
710
+ pass
711
+
712
+ @choice("plain", help="a plain session", short="p")
713
+ class Plain:
714
+ pass
715
+
716
+ app = strictcli.App(name="myapp", version="1.0.0", help="test app")
717
+
718
+ @app.command("launch", effect="read_only", help="launch it")
719
+ @choice_flag(
720
+ "start", help="how to start", presence="required",
721
+ elect_by="member-flags", choices=[Role, Cont, Plain],
722
+ )
723
+ def launch(ctx, start: Role | Cont | Plain):
724
+ match start:
725
+ case Role(value=v):
726
+ print(f"role={v}")
727
+ case Cont():
728
+ print("cont")
729
+ case Plain():
730
+ print("plain")
731
+
732
+ return app
733
+
734
+
735
+ def test_a_payload_carrying_member_elects_by_its_short():
736
+ r = _short_app().test(["launch", "-r", "admin"])
737
+ assert r.exit_code == 0
738
+ assert r.stdout == "role=admin\n"
739
+
740
+
741
+ def test_a_payload_carrying_members_short_consumes_the_next_token():
742
+ """`-r X` is the member's own value, exactly as `--role X` is."""
743
+ r = _short_app().test(["launch", "-r", "--plain"])
744
+ assert r.exit_code == 0
745
+ assert r.stdout == "role=--plain\n"
746
+
747
+
748
+ def test_a_payload_less_member_elects_by_its_short():
749
+ r = _short_app().test(["launch", "-c"])
750
+ assert r.exit_code == 0
751
+ assert r.stdout == "cont\n"
752
+
753
+
754
+ def test_a_member_short_is_refused_a_value():
755
+ r = _short_app().test(["launch", "-c", "extra"])
756
+ assert r.exit_code == 1
757
+ assert "unexpected argument 'extra'" in r.stderr
758
+
759
+
760
+ def test_a_member_with_a_short_is_still_declined_by_the_long_negation():
761
+ """The short elects; the decline keeps its one spelling (§21.2)."""
762
+ r = _short_app().test(["launch", "--no-cont"])
763
+ assert r.exit_code == 1
764
+ assert (
765
+ "error: one of --role, --cont, --plain is required "
766
+ "(--no-cont declines an option; it does not choose one)\n"
767
+ ) in r.stderr
768
+
769
+
770
+ def test_a_decline_beside_a_short_election_names_the_members_long_form():
771
+ """§21's A4 error, reached through a short: the message never says `-p`."""
772
+ r = _short_app().test(["launch", "--no-cont", "-p"])
773
+ assert r.exit_code == 1
774
+ assert (
775
+ "error: --no-cont cannot be combined with --plain "
776
+ "(--no-cont declines an option; it does not choose one)\n"
777
+ ) in r.stderr
778
+
779
+
780
+ def test_two_members_elected_by_short_are_mutually_exclusive():
781
+ r = _short_app().test(["launch", "-c", "-p"])
782
+ assert r.exit_code == 1
783
+ assert "error: --cont and --plain are mutually exclusive\n" in r.stderr
784
+
785
+
786
+ def test_a_member_short_renders_beside_the_member_in_help():
787
+ r = _short_app().test(["launch", "--help"])
788
+ assert r.exit_code == 0
789
+ assert "--role, -r <str>" in r.stdout
790
+ assert "--cont, -c" in r.stdout
791
+ assert "--plain, -p" in r.stdout
792
+
793
+
794
+ def test_a_member_short_is_claimed_against_a_command_flag():
795
+ @choice("role", help="one role")
796
+ class Role:
797
+ value: str = member_value(help="the role name", short="r")
798
+
799
+ @choice("plain", help="a plain session")
800
+ class Plain:
801
+ pass
802
+
803
+ app = strictcli.App(name="myapp", version="1.0.0", help="test app")
804
+
805
+ with pytest.raises(ValueError) as exc:
806
+
807
+ @app.command("launch", effect="read_only", help="launch it")
808
+ @strictcli.flag("repo", help="the repo", short="r", presence="optional")
809
+ @choice_flag(
810
+ "start", help="how to start", presence="required",
811
+ elect_by="member-flags", choices=[Role, Plain],
812
+ )
813
+ def launch(ctx, start: Role | Plain, repo: str):
814
+ pass
815
+
816
+ assert str(exc.value) == (
817
+ 'command "launch": short \'-r\' is claimed by \'--role\' and '
818
+ "'--repo', which can be elected at the same time"
819
+ )
820
+
821
+
822
+ def test_two_sibling_members_may_not_share_a_short():
823
+ """An election token is read before any election has happened (§24.7)."""
824
+
825
+ @choice("cont", help="continue", short="c")
826
+ class Cont:
827
+ pass
828
+
829
+ @choice("clean", help="start clean", short="c")
830
+ class Clean:
831
+ pass
832
+
833
+ app = strictcli.App(name="myapp", version="1.0.0", help="test app")
834
+
835
+ with pytest.raises(ValueError) as exc:
836
+
837
+ @app.command("launch", effect="read_only", help="launch it")
838
+ @choice_flag(
839
+ "start", help="how to start", presence="required",
840
+ elect_by="member-flags", choices=[Cont, Clean],
841
+ )
842
+ def launch(ctx, start: Cont | Clean):
843
+ pass
844
+
845
+ assert str(exc.value) == (
846
+ 'command "launch": short \'-c\' is reused by sibling scopes and also '
847
+ "claimed by '--cont', which elects: an election token is read before "
848
+ "any election has happened, so its short cannot be shared"
849
+ )
850
+
851
+
852
+ def test_a_token_spelled_choice_cannot_carry_a_short():
853
+ @choice("email", help="by email", short="e")
854
+ class Email:
855
+ pass
856
+
857
+ @choice("sms", help="by sms")
858
+ class Sms:
859
+ pass
860
+
861
+ with pytest.raises(ValueError) as exc:
862
+ choice_flag(
863
+ "via", help="delivery channel", presence="required",
864
+ elect_by="selector-token", choices=[Email, Sms],
865
+ )
866
+ assert str(exc.value) == (
867
+ 'Choice "email" of "via": a token-spelled choice cannot carry a '
868
+ "short: the token names the choice, and only a member-spelled choice "
869
+ "has a flag of its own to carry one"
870
+ )
871
+
872
+
873
+ def test_a_payload_carrying_member_declares_its_short_on_the_payload():
874
+ @choice("role", help="one role", short="r")
875
+ class Role:
876
+ value: str = member_value(help="the role name")
877
+
878
+ @choice("plain", help="a plain session")
879
+ class Plain:
880
+ pass
881
+
882
+ with pytest.raises(ValueError) as exc:
883
+ choice_flag(
884
+ "start", help="how to start", presence="required",
885
+ elect_by="member-flags", choices=[Role, Plain],
886
+ )
887
+ assert str(exc.value) == (
888
+ 'Choice "role" of "start": a payload-carrying member declares its '
889
+ "short on its payload: member_value(short=...)"
890
+ )
891
+
892
+
893
+ def test_a_member_short_is_published_on_the_payload_entry(tmp_path, monkeypatch):
894
+ """§25.6's `value` entry is an ordinary flag entry, short included."""
895
+ import json
896
+
897
+ (tmp_path / "pyproject.toml").write_text(
898
+ '[project]\nname = "myapp"\nversion = "1.0.0"\n',
899
+ )
900
+ monkeypatch.chdir(tmp_path)
901
+ r = _short_app().test(["--dump-schema"])
902
+ assert r.exit_code == 0
903
+ dumped = json.loads((tmp_path / ".strictcli" / "schema.json").read_text())
904
+ sel = dumped["commands"]["launch"]["flags"][0]
905
+ role = sel["choices"][0]
906
+ assert role["name"] == "role"
907
+ assert role["flags"][0]["name"] == "value"
908
+ assert role["flags"][0]["short"] == "r"
909
+
910
+
911
+ def test_a_short_with_nothing_after_it_is_named_as_it_was_typed():
912
+ """The refusal quotes the TOKEN, not the long form it resolved to.
913
+
914
+ The root-scope path has always reported the token as typed; the scoped one
915
+ reported the long name, which made a `-r` at the end of argv produce a
916
+ message about `--role` in this implementation alone.
917
+ """
918
+ r = _short_app().test(["launch", "-r"])
919
+ assert r.exit_code == 1
920
+ assert "error: flag '-r' requires a value\n" in r.stderr
921
+
922
+
923
+ def test_a_long_member_token_with_nothing_after_it_is_named_in_full():
924
+ r = _short_app().test(["launch", "--role"])
925
+ assert r.exit_code == 1
926
+ assert "error: flag '--role' requires a value\n" in r.stderr
@@ -2164,3 +2164,29 @@ def test_the_machine_boundary_reads_the_same_sentence():
2164
2164
  "flag '--watch' must be passed as --watch or --no-watch "
2165
2165
  "under '--mode watched'"
2166
2166
  )
2167
+
2168
+
2169
+ def test_a_scoped_flags_short_with_no_value_is_named_as_it_was_typed():
2170
+ """§24.3: the scoped path quotes the token, as the root path always has."""
2171
+
2172
+ @choice("a", help="mode a")
2173
+ class A:
2174
+ target: str = sub_flag(help="the target", presence="required", short="t")
2175
+
2176
+ @choice("b", help="mode b")
2177
+ class B:
2178
+ pass
2179
+
2180
+ app = strictcli.App(name="myapp", version="1.0.0", help="test app")
2181
+
2182
+ @app.command("run", effect="read_only", help="run it")
2183
+ @choice_flag(
2184
+ "mode", help="the mode", presence="required",
2185
+ elect_by="selector-token", choices=[A, B],
2186
+ )
2187
+ def run(ctx, mode: A | B):
2188
+ print(repr(mode))
2189
+
2190
+ r = app.test(["run", "--mode", "a", "-t"])
2191
+ assert r.exit_code == 1
2192
+ assert "error: flag '-t' requires a value\n" in r.stderr
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.41.0"
235
+ version = "0.41.2"
236
236
  source = { editable = "." }
237
237
  dependencies = [
238
238
  { name = "tomlkit" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes