strictcli 0.26.0__tar.gz → 0.27.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.
- {strictcli-0.26.0 → strictcli-0.27.0}/PKG-INFO +1 -1
- {strictcli-0.26.0 → strictcli-0.27.0}/package-lock.json +2 -2
- {strictcli-0.26.0 → strictcli-0.27.0}/package.json +1 -1
- {strictcli-0.26.0 → strictcli-0.27.0}/pyproject.toml +1 -1
- {strictcli-0.26.0 → strictcli-0.27.0}/strictcli/__init__.py +211 -30
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_config.py +113 -3
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_config_fields.py +104 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_dump_schema.py +55 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/uv.lock +1 -1
- {strictcli-0.26.0 → strictcli-0.27.0}/.claude/settings.json +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/.github/workflows/ci-pypi.yml +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/.github/workflows/ci.yml +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/.github/workflows/publish.yml +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/.gitignore +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/.npmignore +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/.rlsbl/config.json +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/.rlsbl/hashes.json +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/.strictcli/schema.json +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/CLAUDE.md +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/LICENSE +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/README.md +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/index.js +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/postinstall.js +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/strictcli/py.typed +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_arg_default.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_at_prefix.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_auto_version.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_call.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_command.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_discovery.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_public_api.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_runner.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_schema.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_types.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_choices.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_choices_none.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_command_tags.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_compound_types.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_config_file_path.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_context.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_dependencies.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_deprecated.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_e2e.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_env.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_exit_codes.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_flag_sets.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_float_type.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_global_flags.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_help.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_hermetic.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_int_type.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_invoke.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_mcp.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_mutex.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_nesting.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_parser.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_passthrough.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_provenance.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_registration.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_repeatable.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_tagdsl.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_toml_loading.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_tool_export.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_typed_args.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_unique.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_utilities.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_validate.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_variadic.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_visibility.py +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/todo/.defer/deferred.md +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/todo/.done/original-idea.md +0 -0
- {strictcli-0.26.0 → strictcli-0.27.0}/todo/.done/public-check-runner-api.md +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strictcli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "strictcli",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.27.0",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT"
|
|
12
12
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
__version__ = "0.
|
|
5
|
+
__version__ = "0.27.0"
|
|
6
6
|
|
|
7
7
|
__all__ = [
|
|
8
8
|
"App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
|
|
@@ -660,21 +660,33 @@ def _generate_config_template_toml(
|
|
|
660
660
|
"""Generate a TOML config template with comments."""
|
|
661
661
|
lines: list[str] = []
|
|
662
662
|
|
|
663
|
+
# A config field whose name equals a flag's param name is validation-only:
|
|
664
|
+
# it annotates the flag and the key is rendered once (on the flag).
|
|
665
|
+
flag_params = {_flag_param_name(f.name) for f in flags}
|
|
666
|
+
colliding = {n: cf for n, cf in config_fields.items() if n in flag_params}
|
|
667
|
+
|
|
663
668
|
# Flag-backed keys (flat)
|
|
664
669
|
for f in flags:
|
|
665
670
|
param = _flag_param_name(f.name)
|
|
666
|
-
|
|
671
|
+
comment = f"# {f.help}"
|
|
672
|
+
cf_collide = colliding.get(param)
|
|
673
|
+
if cf_collide is not None:
|
|
674
|
+
comment += f" -- {cf_collide.help}"
|
|
675
|
+
lines.append(comment)
|
|
667
676
|
if f.default is not None:
|
|
668
677
|
lines.append(f"{param} = {_toml_format_scalar(f.default)}")
|
|
669
678
|
else:
|
|
670
679
|
lines.append(f"# {param} =")
|
|
671
680
|
lines.append("")
|
|
672
681
|
|
|
673
|
-
# Config field keys (possibly nested via dot names)
|
|
682
|
+
# Config field keys (possibly nested via dot names). Skip colliding fields
|
|
683
|
+
# (already rendered on the flag line above).
|
|
674
684
|
# Group by first segment for TOML sections
|
|
675
685
|
top_level: list[tuple[str, "ConfigField"]] = []
|
|
676
686
|
sections: dict[str, list[tuple[str, "ConfigField"]]] = {}
|
|
677
687
|
for name, cf in config_fields.items():
|
|
688
|
+
if name in colliding:
|
|
689
|
+
continue
|
|
678
690
|
parts = name.split(".")
|
|
679
691
|
if len(parts) == 1:
|
|
680
692
|
top_level.append((name, cf))
|
|
@@ -731,6 +743,9 @@ def _generate_config_template_json(
|
|
|
731
743
|
) -> str:
|
|
732
744
|
"""Generate a JSON config template."""
|
|
733
745
|
data: dict = {}
|
|
746
|
+
# A config field colliding with a flag's param name is validation-only; the
|
|
747
|
+
# flag owns the rendered value, so the key appears once.
|
|
748
|
+
flag_params = {_flag_param_name(f.name) for f in flags}
|
|
734
749
|
# Flag-backed keys
|
|
735
750
|
for f in flags:
|
|
736
751
|
param = _flag_param_name(f.name)
|
|
@@ -739,8 +754,11 @@ def _generate_config_template_json(
|
|
|
739
754
|
else:
|
|
740
755
|
data[param] = None
|
|
741
756
|
|
|
742
|
-
# Config field keys (nested via dot names)
|
|
757
|
+
# Config field keys (nested via dot names). Skip colliding fields (rendered
|
|
758
|
+
# once via the flag above).
|
|
743
759
|
for name, cf in config_fields.items():
|
|
760
|
+
if name in flag_params:
|
|
761
|
+
continue
|
|
744
762
|
if not cf.required:
|
|
745
763
|
_nested_set(data, name, cf.default)
|
|
746
764
|
else:
|
|
@@ -787,6 +805,43 @@ def _split_escaped(value: str, sep: str) -> list[str]:
|
|
|
787
805
|
return parts
|
|
788
806
|
|
|
789
807
|
|
|
808
|
+
def _values_equal_for_conflict(cli_val: object, config_val: object, flag: "Flag") -> bool:
|
|
809
|
+
"""Compare a CLI/env value and a config value for conflict-mode equality.
|
|
810
|
+
|
|
811
|
+
Equality semantics (pinned):
|
|
812
|
+
- scalars: exact equality.
|
|
813
|
+
- plain repeatable lists: order-sensitive exact equality.
|
|
814
|
+
- Unique flags: order-insensitive multiset equality.
|
|
815
|
+
|
|
816
|
+
When the two values are equal, config+CLI/env co-presence is NOT a conflict
|
|
817
|
+
(they agree), so error mode does not fire.
|
|
818
|
+
"""
|
|
819
|
+
if flag.unique is True and isinstance(cli_val, list) and isinstance(config_val, list):
|
|
820
|
+
# Order-insensitive multiset comparison.
|
|
821
|
+
return sorted(cli_val, key=repr) == sorted(config_val, key=repr)
|
|
822
|
+
return cli_val == config_val
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _check_flag_configfield_default(
|
|
826
|
+
flag_name: str, flag_default: object, cf: "ConfigField"
|
|
827
|
+
) -> None:
|
|
828
|
+
"""Raise ValueError when a colliding flag and config field have conflicting
|
|
829
|
+
explicit defaults.
|
|
830
|
+
|
|
831
|
+
A ConfigField whose name equals a flag's param name is a validation-only
|
|
832
|
+
declaration -- it annotates the flag. Their defaults must agree. The matrix:
|
|
833
|
+
both absent OK; equal OK; both present unequal = error; one absent OK (the
|
|
834
|
+
flag's default wins for rendering). A flag default of None means "no
|
|
835
|
+
default" (absent); a ConfigField default of _MISSING means absent.
|
|
836
|
+
"""
|
|
837
|
+
flag_has_default = flag_default is not None
|
|
838
|
+
cf_has_default = not isinstance(cf.default, _MissingSentinel)
|
|
839
|
+
if flag_has_default and cf_has_default and flag_default != cf.default:
|
|
840
|
+
raise ValueError(
|
|
841
|
+
f'config field "{cf.name}" collides with flag "{flag_name}" but their defaults disagree ({cf.default!r} vs {flag_default!r}); remove one default or make them equal'
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
|
|
790
845
|
def _find_duplicate(values: list) -> object | None:
|
|
791
846
|
"""Return the first duplicate value in the list, or None if all unique."""
|
|
792
847
|
seen: set = set()
|
|
@@ -1398,6 +1453,11 @@ class Flag:
|
|
|
1398
1453
|
validate: Callable | None = None
|
|
1399
1454
|
repeatable: bool = False
|
|
1400
1455
|
unique: object = _MISSING
|
|
1456
|
+
# Per-flag config conflict mode. _MISSING means "inherit the app default".
|
|
1457
|
+
# When set explicitly, must be "cli-wins" or "error". Applies to flags only:
|
|
1458
|
+
# standalone ConfigFields have no CLI/env conflict surface, and a
|
|
1459
|
+
# flag-colliding ConfigField inherits the flag's handling.
|
|
1460
|
+
conflict_mode: object = _MISSING
|
|
1401
1461
|
# Compound type fields (set by __post_init__, not by caller)
|
|
1402
1462
|
compound: str = "scalar" # "scalar", "list", or "dict"
|
|
1403
1463
|
item_type: type | None = None # for list[T]: the T
|
|
@@ -1481,6 +1541,13 @@ class Flag:
|
|
|
1481
1541
|
raise ValueError(f'Flag "{self.name}": unique requires repeatable=True')
|
|
1482
1542
|
if isinstance(self.unique, _MissingSentinel) and not self.repeatable:
|
|
1483
1543
|
self.unique = False
|
|
1544
|
+
# Validate conflict_mode (per-flag override of the app config conflict mode)
|
|
1545
|
+
if not isinstance(self.conflict_mode, _MissingSentinel):
|
|
1546
|
+
if self.conflict_mode not in ("cli-wins", "error"):
|
|
1547
|
+
raise ValueError(
|
|
1548
|
+
f'Flag "{self.name}": conflict_mode must be "cli-wins" or '
|
|
1549
|
+
f'"error", got {self.conflict_mode!r}'
|
|
1550
|
+
)
|
|
1484
1551
|
# Validate env_separator
|
|
1485
1552
|
if self.compound == "dict":
|
|
1486
1553
|
# Dict flags use JSON for env vars, not env_separator
|
|
@@ -2140,6 +2207,18 @@ class App:
|
|
|
2140
2207
|
"""Return the resolved config file path for this app."""
|
|
2141
2208
|
return _config_path(self.name, override=self.config_path, config_format=self.config_format)
|
|
2142
2209
|
|
|
2210
|
+
def dump_schema_dict(self) -> dict:
|
|
2211
|
+
"""Return the app's full schema as a dict, excluding ``project_id``.
|
|
2212
|
+
|
|
2213
|
+
This is the public, CWD-free accessor for the schema. Unlike the
|
|
2214
|
+
``--dump-schema`` flag (which writes ``.strictcli/schema.json`` and
|
|
2215
|
+
derives ``project_id`` from ``pyproject.toml`` in the current working
|
|
2216
|
+
directory), this method reads only the in-memory ``App`` and performs
|
|
2217
|
+
no filesystem or CWD access. The returned dict is byte-identical to the
|
|
2218
|
+
written schema file with the ``project_id`` field removed.
|
|
2219
|
+
"""
|
|
2220
|
+
return _dump_schema_core(self)
|
|
2221
|
+
|
|
2143
2222
|
def config_field(
|
|
2144
2223
|
self,
|
|
2145
2224
|
name: str,
|
|
@@ -2175,6 +2254,13 @@ class App:
|
|
|
2175
2254
|
f'config field name "{name}" conflicts with framework field'
|
|
2176
2255
|
)
|
|
2177
2256
|
cf = ConfigField(name=name, type=type, help=help, default=default)
|
|
2257
|
+
# A config field colliding with an existing flag's param name is a
|
|
2258
|
+
# validation-only declaration that annotates the flag; their defaults
|
|
2259
|
+
# must agree. Flags registered after this field are checked from the
|
|
2260
|
+
# command-builder side instead.
|
|
2261
|
+
for f in self._collect_all_flags():
|
|
2262
|
+
if _flag_param_name(f.name) == name:
|
|
2263
|
+
_check_flag_configfield_default(f.name, f.default, cf)
|
|
2178
2264
|
self._config_fields[name] = cf
|
|
2179
2265
|
return cf
|
|
2180
2266
|
|
|
@@ -2596,6 +2682,22 @@ class App:
|
|
|
2596
2682
|
_collect_from_group(grp)
|
|
2597
2683
|
return flags
|
|
2598
2684
|
|
|
2685
|
+
def _colliding_config_fields(self) -> dict[str, ConfigField]:
|
|
2686
|
+
"""Return {flag_param_name: ConfigField} for config fields whose name
|
|
2687
|
+
equals a flag's param name.
|
|
2688
|
+
|
|
2689
|
+
Such config fields are validation-only: they annotate the colliding
|
|
2690
|
+
flag rather than rendering as a separate config key. Callers use this to
|
|
2691
|
+
render the key once (on the flag line, with the config field's help as a
|
|
2692
|
+
trailing annotation).
|
|
2693
|
+
"""
|
|
2694
|
+
flag_params = {_flag_param_name(f.name) for f in self._collect_all_flags()}
|
|
2695
|
+
return {
|
|
2696
|
+
name: cf
|
|
2697
|
+
for name, cf in self._config_fields.items()
|
|
2698
|
+
if name in flag_params
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2599
2701
|
def _register_config_group(self) -> None:
|
|
2600
2702
|
"""Register the auto-generated 'config' command group."""
|
|
2601
2703
|
config_grp = Group(
|
|
@@ -2631,14 +2733,18 @@ class App:
|
|
|
2631
2733
|
use_json = _kw.get("json", False)
|
|
2632
2734
|
config_data = app_ref._config_data
|
|
2633
2735
|
all_flags = app_ref._collect_all_flags()
|
|
2736
|
+
colliding = app_ref._colliding_config_fields()
|
|
2634
2737
|
if use_json:
|
|
2635
2738
|
result = {}
|
|
2636
2739
|
for f in all_flags:
|
|
2637
2740
|
param = _flag_param_name(f.name)
|
|
2638
2741
|
value, source = _resolve_flag_show_source(f, config_data)
|
|
2639
2742
|
result[param] = {"value": value, "source": source}
|
|
2640
|
-
# Include config fields
|
|
2743
|
+
# Include config fields (skip those colliding with a flag: they
|
|
2744
|
+
# are validation-only and render once, on the flag entry).
|
|
2641
2745
|
for cf_name, cf in app_ref._config_fields.items():
|
|
2746
|
+
if cf_name in colliding:
|
|
2747
|
+
continue
|
|
2642
2748
|
found, value = _nested_get(config_data, cf_name)
|
|
2643
2749
|
if found:
|
|
2644
2750
|
source = "config"
|
|
@@ -2664,12 +2770,22 @@ class App:
|
|
|
2664
2770
|
for f in all_flags:
|
|
2665
2771
|
param = _flag_param_name(f.name)
|
|
2666
2772
|
value, source = _resolve_flag_show_source(f, config_data)
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2773
|
+
line = f"{param} = {_format_config_value(value)} (source: {source})"
|
|
2774
|
+
# A colliding config field annotates the flag line (rendered once).
|
|
2775
|
+
cf_collide = colliding.get(param)
|
|
2776
|
+
if cf_collide is not None:
|
|
2777
|
+
line += f" -- {cf_collide.help}"
|
|
2778
|
+
print(line)
|
|
2779
|
+
# Include config fields in plain output (skip colliding ones: they
|
|
2780
|
+
# are rendered as an annotation on the flag line above).
|
|
2781
|
+
non_colliding_fields = {
|
|
2782
|
+
n: cf for n, cf in app_ref._config_fields.items()
|
|
2783
|
+
if n not in colliding
|
|
2784
|
+
}
|
|
2785
|
+
if non_colliding_fields:
|
|
2670
2786
|
print()
|
|
2671
2787
|
print("Config fields:")
|
|
2672
|
-
for cf_name, cf in
|
|
2788
|
+
for cf_name, cf in non_colliding_fields.items():
|
|
2673
2789
|
found, value = _nested_get(config_data, cf_name)
|
|
2674
2790
|
if found:
|
|
2675
2791
|
source = "config"
|
|
@@ -3579,14 +3695,28 @@ class App:
|
|
|
3579
3695
|
param = _flag_param_name(f.name)
|
|
3580
3696
|
if param not in self._config_data:
|
|
3581
3697
|
continue
|
|
3698
|
+
# Effective mode: per-flag override if set, else the app default.
|
|
3699
|
+
effective_mode = (
|
|
3700
|
+
f.conflict_mode
|
|
3701
|
+
if not isinstance(f.conflict_mode, _MissingSentinel)
|
|
3702
|
+
else self.config_conflict_mode
|
|
3703
|
+
)
|
|
3582
3704
|
if f.name in cli_set:
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3705
|
+
# Conflict ONLY when config diverges from the CLI/env value.
|
|
3706
|
+
if effective_mode == "error":
|
|
3707
|
+
try:
|
|
3708
|
+
coerced = _coerce_config_value(self._config_data[param], f)
|
|
3709
|
+
except ValueError as e:
|
|
3710
|
+
raise _ParseError(
|
|
3711
|
+
f"--{f.name}: config value error: {e}"
|
|
3712
|
+
)
|
|
3713
|
+
if not _values_equal_for_conflict(cli_set[f.name], coerced, f):
|
|
3714
|
+
existing_source = global_sources.get(param, "cli")
|
|
3715
|
+
raise _ParseError(
|
|
3716
|
+
f"flag '{f.name}' set in both "
|
|
3717
|
+
f"{existing_source} and config; remove one"
|
|
3718
|
+
)
|
|
3719
|
+
continue # cli-wins, or error mode with matching values
|
|
3590
3720
|
try:
|
|
3591
3721
|
coerced = _coerce_config_value(self._config_data[param], f)
|
|
3592
3722
|
except ValueError as e:
|
|
@@ -4737,15 +4867,29 @@ def _parse_command(
|
|
|
4737
4867
|
param = _flag_param_name(f.name)
|
|
4738
4868
|
if param not in config_data:
|
|
4739
4869
|
continue
|
|
4870
|
+
# Effective mode: per-flag override if set, else the app default.
|
|
4871
|
+
effective_mode = (
|
|
4872
|
+
f.conflict_mode
|
|
4873
|
+
if not isinstance(f.conflict_mode, _MissingSentinel)
|
|
4874
|
+
else conflict_mode
|
|
4875
|
+
)
|
|
4740
4876
|
if f.name in cli_set:
|
|
4741
|
-
# Flag set by CLI or env, config also has a value
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
|
|
4747
|
-
|
|
4748
|
-
|
|
4877
|
+
# Flag set by CLI or env, config also has a value. This is a
|
|
4878
|
+
# conflict ONLY when the values diverge; identical values agree.
|
|
4879
|
+
if effective_mode == "error":
|
|
4880
|
+
try:
|
|
4881
|
+
coerced = _coerce_config_value(config_data[param], f)
|
|
4882
|
+
except ValueError as e:
|
|
4883
|
+
raise _ParseError(
|
|
4884
|
+
f"--{f.name}: config value error: {e}"
|
|
4885
|
+
)
|
|
4886
|
+
if not _values_equal_for_conflict(cli_set[f.name], coerced, f):
|
|
4887
|
+
existing_source = "env" if f.name in env_names else "cli"
|
|
4888
|
+
raise _ParseError(
|
|
4889
|
+
f"flag '{f.name}' set in both "
|
|
4890
|
+
f"{existing_source} and config; remove one"
|
|
4891
|
+
)
|
|
4892
|
+
continue # cli-wins, or error mode with matching values
|
|
4749
4893
|
try:
|
|
4750
4894
|
coerced = _coerce_config_value(config_data[param], f)
|
|
4751
4895
|
except ValueError as e:
|
|
@@ -4923,6 +5067,15 @@ def _build_and_validate_command(
|
|
|
4923
5067
|
f'command "{name}": flag "{f.name}" collides with a global flag'
|
|
4924
5068
|
)
|
|
4925
5069
|
|
|
5070
|
+
# Validate: a command flag colliding with a config field (validation-only
|
|
5071
|
+
# coexistence) must have an agreeing default. Config fields registered after
|
|
5072
|
+
# this command are checked from the App.config_field() side instead.
|
|
5073
|
+
if config_fields_ref:
|
|
5074
|
+
for f in all_flags:
|
|
5075
|
+
cf = config_fields_ref.get(_flag_param_name(f.name))
|
|
5076
|
+
if cf is not None:
|
|
5077
|
+
_check_flag_configfield_default(f.name, f.default, cf)
|
|
5078
|
+
|
|
4926
5079
|
# Validate: no duplicate arg names
|
|
4927
5080
|
seen_arg_names: set[str] = set()
|
|
4928
5081
|
for a in all_args:
|
|
@@ -5117,6 +5270,7 @@ def flag(
|
|
|
5117
5270
|
validate: Callable | None = None,
|
|
5118
5271
|
repeatable: bool = False,
|
|
5119
5272
|
unique: object = _MISSING,
|
|
5273
|
+
conflict_mode: object = _MISSING,
|
|
5120
5274
|
) -> Callable[[F], F]:
|
|
5121
5275
|
"""Module-level decorator to attach a Flag to a command handler."""
|
|
5122
5276
|
|
|
@@ -5135,6 +5289,7 @@ def flag(
|
|
|
5135
5289
|
validate=validate,
|
|
5136
5290
|
repeatable=repeatable,
|
|
5137
5291
|
unique=unique,
|
|
5292
|
+
conflict_mode=conflict_mode,
|
|
5138
5293
|
)
|
|
5139
5294
|
if not hasattr(func, "_strictcli_flags"):
|
|
5140
5295
|
func._strictcli_flags = []
|
|
@@ -5970,6 +6125,12 @@ def _serialize_flag(f: Flag) -> dict:
|
|
|
5970
6125
|
d["repeatable"] = f.repeatable
|
|
5971
6126
|
if f.unique is True:
|
|
5972
6127
|
d["unique"] = True
|
|
6128
|
+
# Per-flag conflict mode: serialized only when explicitly set (omitted when
|
|
6129
|
+
# inheriting the app default). This is additive; schema_version stays 1, so
|
|
6130
|
+
# consumers get no version signal for this field -- they must treat its
|
|
6131
|
+
# absence as "inherit the app-level config_conflict_mode".
|
|
6132
|
+
if not isinstance(f.conflict_mode, _MissingSentinel):
|
|
6133
|
+
d["conflict_mode"] = f.conflict_mode
|
|
5973
6134
|
if f.env_separator is not None:
|
|
5974
6135
|
d["env_separator"] = f.env_separator
|
|
5975
6136
|
negatable = f.negatable if f.type is bool and f.compound == "scalar" else None
|
|
@@ -6186,17 +6347,20 @@ def _collect_config_field_bindings_from_group(
|
|
|
6186
6347
|
_collect_config_field_bindings_from_group(sub, bindings, group_path)
|
|
6187
6348
|
|
|
6188
6349
|
|
|
6189
|
-
def
|
|
6190
|
-
"""
|
|
6350
|
+
def _dump_schema_core(app: App) -> dict:
|
|
6351
|
+
"""Build the full schema dict, excluding ``project_id``.
|
|
6191
6352
|
|
|
6192
|
-
|
|
6193
|
-
|
|
6353
|
+
This is the CWD-free, filesystem-free core of schema production. It reads
|
|
6354
|
+
only the in-memory ``App`` (name, version, help, flags, commands, groups,
|
|
6355
|
+
etc.). ``project_id`` is added later by the file-writer path, since it is
|
|
6356
|
+
the only field that requires reading ``pyproject.toml`` from the CWD.
|
|
6357
|
+
|
|
6358
|
+
Fields whose values match the schema defaults are omitted. The top-level
|
|
6359
|
+
``defaults`` key documents what each missing field means.
|
|
6194
6360
|
"""
|
|
6195
|
-
project_id = _read_project_id()
|
|
6196
6361
|
schema: dict = {
|
|
6197
6362
|
"schema_version": 1,
|
|
6198
6363
|
"defaults": _build_schema_defaults(),
|
|
6199
|
-
"project_id": project_id,
|
|
6200
6364
|
"name": app.name,
|
|
6201
6365
|
"version": app.version,
|
|
6202
6366
|
"help": app.help,
|
|
@@ -6260,6 +6424,23 @@ def _dump_schema(app: App) -> dict:
|
|
|
6260
6424
|
return schema
|
|
6261
6425
|
|
|
6262
6426
|
|
|
6427
|
+
def _dump_schema(app: App) -> dict:
|
|
6428
|
+
"""Produce the full schema dict including ``project_id`` (reads the CWD).
|
|
6429
|
+
|
|
6430
|
+
Delegates the bulk of the work to :func:`_dump_schema_core` and inserts
|
|
6431
|
+
``project_id`` immediately after ``defaults`` so the on-disk layout is
|
|
6432
|
+
stable and byte-identical to the core dict once ``project_id`` is removed.
|
|
6433
|
+
"""
|
|
6434
|
+
core = _dump_schema_core(app)
|
|
6435
|
+
project_id = _read_project_id()
|
|
6436
|
+
result: dict = {}
|
|
6437
|
+
for key, value in core.items():
|
|
6438
|
+
result[key] = value
|
|
6439
|
+
if key == "defaults":
|
|
6440
|
+
result["project_id"] = project_id
|
|
6441
|
+
return result
|
|
6442
|
+
|
|
6443
|
+
|
|
6263
6444
|
def _check_schema_project_id(file_path: str, new_project_id: str) -> None:
|
|
6264
6445
|
"""Verify that an existing schema file belongs to the same project.
|
|
6265
6446
|
|
|
@@ -1682,10 +1682,16 @@ def test_conflict_mode_implied_excluded(tmp_path, monkeypatch):
|
|
|
1682
1682
|
|
|
1683
1683
|
|
|
1684
1684
|
def test_conflict_mode_fires_before_mutex(tmp_path, monkeypatch):
|
|
1685
|
-
"""Conflict fires before mutex when both would error.
|
|
1685
|
+
"""Conflict fires before mutex when both would error.
|
|
1686
|
+
|
|
1687
|
+
Under divergence-awareness, a conflict requires the config value to differ
|
|
1688
|
+
from the CLI value. Config sets format_json=false while the CLI passes
|
|
1689
|
+
--format-json (true): the values diverge, so the conflict fires. The CLI
|
|
1690
|
+
also passes --format-yaml so mutex would also fire -- conflict wins.
|
|
1691
|
+
"""
|
|
1686
1692
|
config_dir = tmp_path / "testapp"
|
|
1687
1693
|
config_dir.mkdir(parents=True)
|
|
1688
|
-
(config_dir / "config.json").write_text('{"format_json":
|
|
1694
|
+
(config_dir / "config.json").write_text('{"format_json": false}')
|
|
1689
1695
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
1690
1696
|
app = strictcli.App(
|
|
1691
1697
|
name="testapp", version="1.0.0", help="test",
|
|
@@ -1701,7 +1707,111 @@ def test_conflict_mode_fires_before_mutex(tmp_path, monkeypatch):
|
|
|
1701
1707
|
def run(format_json, format_yaml):
|
|
1702
1708
|
pass
|
|
1703
1709
|
|
|
1704
|
-
r = app.test(["run", "--format-json"])
|
|
1710
|
+
r = app.test(["run", "--format-json", "--format-yaml"])
|
|
1705
1711
|
assert r.exit_code == 1
|
|
1706
1712
|
# Should mention conflict (set in both), not mutex
|
|
1707
1713
|
assert "set in both" in r.stderr
|
|
1714
|
+
|
|
1715
|
+
|
|
1716
|
+
# --- Phase 2.2: divergence-aware conflict mode + per-flag override ---
|
|
1717
|
+
|
|
1718
|
+
def _conflict_app(conflict_mode="error", flag_conflict_mode=strictcli._MISSING,
|
|
1719
|
+
flag_type=str, default="default-val", unique=strictcli._MISSING,
|
|
1720
|
+
repeatable=False):
|
|
1721
|
+
"""Build an app with a single 'run' command flag 'target'."""
|
|
1722
|
+
app = strictcli.App(
|
|
1723
|
+
name="testapp", version="1.0.0", help="test",
|
|
1724
|
+
config=True, config_conflict_mode=conflict_mode,
|
|
1725
|
+
)
|
|
1726
|
+
|
|
1727
|
+
@app.command("run", help="run")
|
|
1728
|
+
@strictcli.flag("target", type=flag_type, help="target", default=default,
|
|
1729
|
+
repeatable=repeatable, unique=unique,
|
|
1730
|
+
conflict_mode=flag_conflict_mode)
|
|
1731
|
+
def run(target):
|
|
1732
|
+
print(f"target={target}")
|
|
1733
|
+
|
|
1734
|
+
return app
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
def _write_cfg(tmp_path, monkeypatch, payload):
|
|
1738
|
+
config_dir = tmp_path / "testapp"
|
|
1739
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
1740
|
+
(config_dir / "config.json").write_text(json.dumps(payload))
|
|
1741
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
1742
|
+
|
|
1743
|
+
|
|
1744
|
+
def test_conflict_error_identical_scalar_passes(tmp_path, monkeypatch):
|
|
1745
|
+
"""Error mode: identical config+CLI value is NOT a conflict."""
|
|
1746
|
+
_write_cfg(tmp_path, monkeypatch, {"target": "same"})
|
|
1747
|
+
app = _conflict_app(conflict_mode="error")
|
|
1748
|
+
r = app.test(["run", "--target", "same"])
|
|
1749
|
+
assert r.exit_code == 0, r.stderr
|
|
1750
|
+
assert "target=same" in r.stdout
|
|
1751
|
+
|
|
1752
|
+
|
|
1753
|
+
def test_conflict_error_divergent_scalar_errors(tmp_path, monkeypatch):
|
|
1754
|
+
"""Error mode: divergent config+CLI value IS a conflict."""
|
|
1755
|
+
_write_cfg(tmp_path, monkeypatch, {"target": "from-config"})
|
|
1756
|
+
app = _conflict_app(conflict_mode="error")
|
|
1757
|
+
r = app.test(["run", "--target", "from-cli"])
|
|
1758
|
+
assert r.exit_code == 1
|
|
1759
|
+
assert "set in both cli and config; remove one" in r.stderr
|
|
1760
|
+
|
|
1761
|
+
|
|
1762
|
+
def test_per_flag_error_beats_app_cli_wins(tmp_path, monkeypatch):
|
|
1763
|
+
"""Per-flag conflict_mode='error' overrides app default cli-wins."""
|
|
1764
|
+
_write_cfg(tmp_path, monkeypatch, {"target": "from-config"})
|
|
1765
|
+
app = _conflict_app(conflict_mode="cli-wins", flag_conflict_mode="error")
|
|
1766
|
+
r = app.test(["run", "--target", "from-cli"])
|
|
1767
|
+
assert r.exit_code == 1
|
|
1768
|
+
assert "set in both cli and config; remove one" in r.stderr
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
def test_per_flag_cli_wins_beats_app_error(tmp_path, monkeypatch):
|
|
1772
|
+
"""Per-flag conflict_mode='cli-wins' overrides app default error."""
|
|
1773
|
+
_write_cfg(tmp_path, monkeypatch, {"target": "from-config"})
|
|
1774
|
+
app = _conflict_app(conflict_mode="error", flag_conflict_mode="cli-wins")
|
|
1775
|
+
r = app.test(["run", "--target", "from-cli"])
|
|
1776
|
+
assert r.exit_code == 0, r.stderr
|
|
1777
|
+
assert "target=from-cli" in r.stdout
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def test_conflict_repeatable_order_sensitive(tmp_path, monkeypatch):
|
|
1781
|
+
"""Plain repeatable: same elements different order diverge -> error."""
|
|
1782
|
+
_write_cfg(tmp_path, monkeypatch, {"target": ["a", "b"]})
|
|
1783
|
+
app = _conflict_app(conflict_mode="error", default=None,
|
|
1784
|
+
repeatable=True, unique=False)
|
|
1785
|
+
# Same order -> equal -> pass
|
|
1786
|
+
r = app.test(["run", "--target", "a", "--target", "b"])
|
|
1787
|
+
assert r.exit_code == 0, r.stderr
|
|
1788
|
+
# Different order -> divergent -> error
|
|
1789
|
+
app2 = _conflict_app(conflict_mode="error", default=None,
|
|
1790
|
+
repeatable=True, unique=False)
|
|
1791
|
+
r2 = app2.test(["run", "--target", "b", "--target", "a"])
|
|
1792
|
+
assert r2.exit_code == 1
|
|
1793
|
+
assert "set in both" in r2.stderr
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def test_conflict_unique_order_insensitive(tmp_path, monkeypatch):
|
|
1797
|
+
"""Unique: same elements different order are equal (multiset) -> pass."""
|
|
1798
|
+
_write_cfg(tmp_path, monkeypatch, {"target": ["a", "b"]})
|
|
1799
|
+
app = _conflict_app(conflict_mode="error", default=None,
|
|
1800
|
+
repeatable=True, unique=True)
|
|
1801
|
+
r = app.test(["run", "--target", "b", "--target", "a"])
|
|
1802
|
+
assert r.exit_code == 0, r.stderr
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def test_conflict_malformed_config_value_errors_cleanly(tmp_path, monkeypatch):
|
|
1806
|
+
"""Error mode co-presence: a malformed config value errors cleanly."""
|
|
1807
|
+
_write_cfg(tmp_path, monkeypatch, {"target": "not-an-int"})
|
|
1808
|
+
app = _conflict_app(conflict_mode="error", flag_type=int, default=0)
|
|
1809
|
+
r = app.test(["run", "--target", "5"])
|
|
1810
|
+
assert r.exit_code == 1
|
|
1811
|
+
assert "config value error" in r.stderr
|
|
1812
|
+
|
|
1813
|
+
|
|
1814
|
+
def test_flag_conflict_mode_invalid_value_raises():
|
|
1815
|
+
"""Registration: invalid per-flag conflict_mode is a ValueError."""
|
|
1816
|
+
with pytest.raises(ValueError, match="conflict_mode"):
|
|
1817
|
+
strictcli.Flag(name="x", type=str, help="h", conflict_mode="bogus")
|
|
@@ -993,3 +993,107 @@ class TestSchemaWithConfigFields:
|
|
|
993
993
|
|
|
994
994
|
bound = schema["config_fields"]["db.url"]["bound_commands"]
|
|
995
995
|
assert "server start" in bound
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
# ---- Phase 2.3: ConfigField / Flag coexistence ----
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
class TestConfigFieldFlagCoexistence:
|
|
1002
|
+
"""A config field colliding with a flag's param name is validation-only:
|
|
1003
|
+
it annotates the flag and renders once."""
|
|
1004
|
+
|
|
1005
|
+
def _app_field_after_flag(self, tmp_path, config_data=None,
|
|
1006
|
+
flag_default=None, field_default=strictcli._MISSING):
|
|
1007
|
+
app = _build_config_app(tmp_path, config_data=config_data or {})
|
|
1008
|
+
|
|
1009
|
+
@app.command(name="run", help="run")
|
|
1010
|
+
@strictcli.flag("target", type=str, help="deploy target", default=flag_default)
|
|
1011
|
+
def run(target):
|
|
1012
|
+
pass
|
|
1013
|
+
|
|
1014
|
+
app.config_field("target", type=str, help="the deploy target",
|
|
1015
|
+
default=field_default)
|
|
1016
|
+
return app
|
|
1017
|
+
|
|
1018
|
+
def test_show_plain_renders_key_once_with_annotation(self, tmp_path):
|
|
1019
|
+
app = self._app_field_after_flag(tmp_path, config_data={"target": "prod"})
|
|
1020
|
+
result = app.test(["config", "show", "--plain"])
|
|
1021
|
+
assert result.exit_code == 0
|
|
1022
|
+
# The flag line carries the config field help as a trailing annotation.
|
|
1023
|
+
target_lines = [ln for ln in result.stdout.splitlines() if ln.startswith("target ")]
|
|
1024
|
+
assert len(target_lines) == 1, result.stdout
|
|
1025
|
+
assert "-- the deploy target" in target_lines[0]
|
|
1026
|
+
# Not duplicated under a "Config fields:" section.
|
|
1027
|
+
assert "Config fields:" not in result.stdout
|
|
1028
|
+
|
|
1029
|
+
def test_show_json_renders_key_once(self, tmp_path):
|
|
1030
|
+
app = self._app_field_after_flag(tmp_path, config_data={"target": "prod"})
|
|
1031
|
+
result = app.test(["config", "show", "--json"])
|
|
1032
|
+
assert result.exit_code == 0
|
|
1033
|
+
data = json.loads(result.stdout)
|
|
1034
|
+
# Single entry: the flag entry (value + source), not the config-field entry.
|
|
1035
|
+
assert data["target"]["value"] == "prod"
|
|
1036
|
+
assert data["target"]["source"] == "config"
|
|
1037
|
+
# The config-field-only keys must be absent (rendered as flag, once).
|
|
1038
|
+
assert "type" not in data["target"]
|
|
1039
|
+
assert "required" not in data["target"]
|
|
1040
|
+
|
|
1041
|
+
def test_init_toml_renders_key_once_with_annotation(self, tmp_path):
|
|
1042
|
+
init_path = str(tmp_path / "cfg.toml")
|
|
1043
|
+
app = strictcli.App(name="testapp", version="1.0.0", help="t",
|
|
1044
|
+
config=True, config_path=init_path, config_format="toml")
|
|
1045
|
+
|
|
1046
|
+
@app.command(name="run", help="run")
|
|
1047
|
+
@strictcli.flag("target", type=str, help="deploy target", default="prod")
|
|
1048
|
+
def run(target):
|
|
1049
|
+
pass
|
|
1050
|
+
|
|
1051
|
+
app.config_field("target", type=str, help="the deploy target", default="prod")
|
|
1052
|
+
result = app.test(["config", "init"])
|
|
1053
|
+
assert result.exit_code == 0
|
|
1054
|
+
content = open(init_path).read()
|
|
1055
|
+
assert content.count("target =") == 1, content
|
|
1056
|
+
assert "-- the deploy target" in content
|
|
1057
|
+
|
|
1058
|
+
def test_init_json_renders_key_once(self, tmp_path):
|
|
1059
|
+
init_path = str(tmp_path / "cfg.json")
|
|
1060
|
+
app = strictcli.App(name="testapp", version="1.0.0", help="t",
|
|
1061
|
+
config=True, config_path=init_path)
|
|
1062
|
+
|
|
1063
|
+
@app.command(name="run", help="run")
|
|
1064
|
+
@strictcli.flag("target", type=str, help="deploy target", default="prod")
|
|
1065
|
+
def run(target):
|
|
1066
|
+
pass
|
|
1067
|
+
|
|
1068
|
+
app.config_field("target", type=str, help="the deploy target", default="prod")
|
|
1069
|
+
result = app.test(["config", "init"])
|
|
1070
|
+
assert result.exit_code == 0
|
|
1071
|
+
data = json.loads(open(init_path).read())
|
|
1072
|
+
assert list(data.keys()).count("target") == 1
|
|
1073
|
+
assert data["target"] == "prod"
|
|
1074
|
+
|
|
1075
|
+
def test_unequal_defaults_field_after_flag_raises(self, tmp_path):
|
|
1076
|
+
with pytest.raises(ValueError, match="defaults disagree"):
|
|
1077
|
+
self._app_field_after_flag(tmp_path, flag_default="prod",
|
|
1078
|
+
field_default="staging")
|
|
1079
|
+
|
|
1080
|
+
def test_unequal_defaults_flag_after_field_raises(self, tmp_path):
|
|
1081
|
+
app = _build_config_app(tmp_path)
|
|
1082
|
+
app.config_field("target", type=str, help="the deploy target",
|
|
1083
|
+
default="staging")
|
|
1084
|
+
with pytest.raises(ValueError, match="defaults disagree"):
|
|
1085
|
+
@app.command(name="run", help="run")
|
|
1086
|
+
@strictcli.flag("target", type=str, help="deploy target", default="prod")
|
|
1087
|
+
def run(target):
|
|
1088
|
+
pass
|
|
1089
|
+
|
|
1090
|
+
def test_equal_defaults_ok(self, tmp_path):
|
|
1091
|
+
# Should not raise.
|
|
1092
|
+
self._app_field_after_flag(tmp_path, flag_default="prod",
|
|
1093
|
+
field_default="prod")
|
|
1094
|
+
|
|
1095
|
+
def test_one_absent_default_ok(self, tmp_path):
|
|
1096
|
+
# Flag has default, config field has none -> OK (flag wins).
|
|
1097
|
+
self._app_field_after_flag(tmp_path, flag_default="prod")
|
|
1098
|
+
# Field has default, flag has none -> OK.
|
|
1099
|
+
self._app_field_after_flag(tmp_path, flag_default=None, field_default="prod")
|
|
@@ -1103,3 +1103,58 @@ class TestSchemaProjectIdMismatch:
|
|
|
1103
1103
|
|
|
1104
1104
|
result = app.test(["--dump-schema"])
|
|
1105
1105
|
assert result.exit_code == 0
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
class TestDumpSchemaDict:
|
|
1109
|
+
"""App.dump_schema_dict() returns the CWD-free schema core dict."""
|
|
1110
|
+
|
|
1111
|
+
def test_returns_version_without_project_id(self, tmp_path, monkeypatch):
|
|
1112
|
+
# Run from a directory with NO pyproject.toml to prove no CWD access.
|
|
1113
|
+
empty = tmp_path / "empty"
|
|
1114
|
+
empty.mkdir()
|
|
1115
|
+
monkeypatch.chdir(empty)
|
|
1116
|
+
app = _make_app()
|
|
1117
|
+
|
|
1118
|
+
@app.command("greet", help="Say hello")
|
|
1119
|
+
def greet():
|
|
1120
|
+
pass
|
|
1121
|
+
|
|
1122
|
+
d = app.dump_schema_dict()
|
|
1123
|
+
assert d["schema_version"] == 1
|
|
1124
|
+
assert d["version"] == "1.0.0"
|
|
1125
|
+
assert d["name"] == "testapp"
|
|
1126
|
+
assert "project_id" not in d
|
|
1127
|
+
assert "commands" in d
|
|
1128
|
+
|
|
1129
|
+
def test_no_pyproject_does_not_raise(self, tmp_path, monkeypatch):
|
|
1130
|
+
empty = tmp_path / "empty2"
|
|
1131
|
+
empty.mkdir()
|
|
1132
|
+
monkeypatch.chdir(empty)
|
|
1133
|
+
assert not (empty / "pyproject.toml").exists()
|
|
1134
|
+
app = _make_app()
|
|
1135
|
+
# Must not raise even though _read_project_id() would.
|
|
1136
|
+
d = app.dump_schema_dict()
|
|
1137
|
+
assert isinstance(d, dict)
|
|
1138
|
+
|
|
1139
|
+
def test_equals_file_writer_minus_project_id(self, tmp_path, monkeypatch):
|
|
1140
|
+
# tmp_path has a pyproject.toml (autouse fixture), so the file writer works.
|
|
1141
|
+
monkeypatch.chdir(tmp_path)
|
|
1142
|
+
app = _make_app(env_prefix="TESTAPP")
|
|
1143
|
+
|
|
1144
|
+
@app.command("greet", help="Say hello")
|
|
1145
|
+
@strictcli.flag("loud", type=bool, help="be loud", default=False)
|
|
1146
|
+
def greet(loud):
|
|
1147
|
+
pass
|
|
1148
|
+
|
|
1149
|
+
result = app.test(["--dump-schema"])
|
|
1150
|
+
assert result.exit_code == 0
|
|
1151
|
+
written = json.loads((tmp_path / ".strictcli" / "schema.json").read_text())
|
|
1152
|
+
method = app.dump_schema_dict()
|
|
1153
|
+
|
|
1154
|
+
# File-writer output minus project_id must equal the method output.
|
|
1155
|
+
written_minus = {k: v for k, v in written.items() if k != "project_id"}
|
|
1156
|
+
assert written_minus == method
|
|
1157
|
+
|
|
1158
|
+
# Byte-identical by construction: serializing the method output equals
|
|
1159
|
+
# serializing the file output with the project_id key removed.
|
|
1160
|
+
assert json.dumps(method, indent=2) == json.dumps(written_minus, indent=2)
|
|
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
|