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.
- {strictcli-0.41.0 → strictcli-0.41.2}/PKG-INFO +10 -5
- {strictcli-0.41.0 → strictcli-0.41.2}/README.md +1 -1
- {strictcli-0.41.0 → strictcli-0.41.2}/pyproject.toml +18 -4
- {strictcli-0.41.0 → strictcli-0.41.2}/strictcli/__init__.py +114 -21
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_coverage.py +36 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_member_spelling.py +238 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_selectors.py +26 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/uv.lock +1 -1
- {strictcli-0.41.0 → strictcli-0.41.2}/.claude/settings.json +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/.github/workflows/ci.yml +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/.github/workflows/publish.yml +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/.gitignore +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/.rlsbl/config.json +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/CLAUDE.md +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/LICENSE +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/scripts/add_effect_classification.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/scripts/add_forwarding_declaration.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/scripts/migrate_presence.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/strictcli/py.typed +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/conftest.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/flagship_app.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_arg_decorator_order.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_arg_default.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_argv_value_order.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_at_prefix.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_auto_version.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_call.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_command.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_discovery.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_provider.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_public_api.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_runner.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_schema.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_check_types.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_choice_records.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_choices.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_choices_none.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_claimed_rendering.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_classification.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_command_tags.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_compound_types.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_config.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_config_fields.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_config_file_path.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_confirm.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_connection_env.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_constraints.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_context.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_deprecated.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_dry_run_unsupported.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_dump_schema.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_e2e.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_effects.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_effects_bypass_check.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_env.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_exit_codes.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_flag_sets.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_flagship_preview.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_flat_pre_typed.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_float_format.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_float_type.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_float_vectors.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_global_flags.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_guard_v2.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_help.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_hermetic.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_infra_env.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_int_type.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_invoke.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_machine_mode.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_mcp.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_nesting.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_owns_stdout.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_parser.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_passthrough.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_payload_schema.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_presence.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_provenance.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_record_pre_typed.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_registration.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_repeatable.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_reserved_quartet.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_tagdsl.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_toml_loading.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_tool_export.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_trace_store.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_typed_args.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_ulid_vectors.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_unique.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_update.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_utilities.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_validate.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_value_sweep_order.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_variadic.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/tests/test_visibility.py +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/todo/.defer/deferred.md +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.41.0 → strictcli-0.41.2}/todo/.done/original-idea.md +0 -0
- {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.
|
|
4
|
-
Summary: A strict CLI framework for Python
|
|
5
|
-
Project-URL: Homepage, https://
|
|
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.
|
|
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 = [
|
|
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://
|
|
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.
|
|
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(
|
|
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: {
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|