strictcli 0.25.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.25.0 → strictcli-0.27.0}/.rlsbl/config.json +2 -1
- {strictcli-0.25.0 → strictcli-0.27.0}/PKG-INFO +1 -1
- {strictcli-0.25.0 → strictcli-0.27.0}/package-lock.json +2 -2
- {strictcli-0.25.0 → strictcli-0.27.0}/package.json +1 -1
- {strictcli-0.25.0 → strictcli-0.27.0}/pyproject.toml +1 -1
- {strictcli-0.25.0 → strictcli-0.27.0}/strictcli/__init__.py +225 -41
- strictcli-0.27.0/strictcli/py.typed +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_config.py +113 -3
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_config_fields.py +104 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_dump_schema.py +55 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/uv.lock +1 -1
- {strictcli-0.25.0 → strictcli-0.27.0}/.claude/settings.json +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/.github/workflows/ci-pypi.yml +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/.github/workflows/ci.yml +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/.github/workflows/publish.yml +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/.gitignore +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/.npmignore +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/.rlsbl/hashes.json +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/.rlsbl/managed-files.json +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/.strictcli/schema.json +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/CLAUDE.md +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/LICENSE +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/README.md +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/index.js +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/postinstall.js +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_arg_default.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_at_prefix.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_auto_version.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_call.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_command.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_discovery.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_public_api.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_runner.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_schema.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_types.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_choices.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_choices_none.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_command_tags.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_compound_types.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_config_file_path.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_context.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_dependencies.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_deprecated.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_e2e.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_env.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_exit_codes.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_flag_sets.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_float_type.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_global_flags.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_help.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_hermetic.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_int_type.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_invoke.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_mcp.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_mutex.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_nesting.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_parser.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_passthrough.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_provenance.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_registration.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_repeatable.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_tagdsl.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_toml_loading.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_tool_export.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_typed_args.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_unique.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_utilities.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_validate.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_variadic.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_visibility.py +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/todo/.defer/deferred.md +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.25.0 → strictcli-0.27.0}/todo/.done/original-idea.md +0 -0
- {strictcli-0.25.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",
|
|
@@ -30,7 +30,10 @@ import tomllib
|
|
|
30
30
|
from collections import deque
|
|
31
31
|
from dataclasses import dataclass, field
|
|
32
32
|
from pathlib import Path
|
|
33
|
-
from typing import Callable, Protocol, get_args, get_origin, runtime_checkable
|
|
33
|
+
from typing import Any, Callable, Protocol, TypeVar, get_args, get_origin, runtime_checkable
|
|
34
|
+
|
|
35
|
+
# TypeVar for decorator return types — preserves the decorated function's type
|
|
36
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
# Sentinel for distinguishing "not provided" from actual values
|
|
@@ -657,21 +660,33 @@ def _generate_config_template_toml(
|
|
|
657
660
|
"""Generate a TOML config template with comments."""
|
|
658
661
|
lines: list[str] = []
|
|
659
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
|
+
|
|
660
668
|
# Flag-backed keys (flat)
|
|
661
669
|
for f in flags:
|
|
662
670
|
param = _flag_param_name(f.name)
|
|
663
|
-
|
|
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)
|
|
664
676
|
if f.default is not None:
|
|
665
677
|
lines.append(f"{param} = {_toml_format_scalar(f.default)}")
|
|
666
678
|
else:
|
|
667
679
|
lines.append(f"# {param} =")
|
|
668
680
|
lines.append("")
|
|
669
681
|
|
|
670
|
-
# 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).
|
|
671
684
|
# Group by first segment for TOML sections
|
|
672
685
|
top_level: list[tuple[str, "ConfigField"]] = []
|
|
673
686
|
sections: dict[str, list[tuple[str, "ConfigField"]]] = {}
|
|
674
687
|
for name, cf in config_fields.items():
|
|
688
|
+
if name in colliding:
|
|
689
|
+
continue
|
|
675
690
|
parts = name.split(".")
|
|
676
691
|
if len(parts) == 1:
|
|
677
692
|
top_level.append((name, cf))
|
|
@@ -728,6 +743,9 @@ def _generate_config_template_json(
|
|
|
728
743
|
) -> str:
|
|
729
744
|
"""Generate a JSON config template."""
|
|
730
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}
|
|
731
749
|
# Flag-backed keys
|
|
732
750
|
for f in flags:
|
|
733
751
|
param = _flag_param_name(f.name)
|
|
@@ -736,8 +754,11 @@ def _generate_config_template_json(
|
|
|
736
754
|
else:
|
|
737
755
|
data[param] = None
|
|
738
756
|
|
|
739
|
-
# 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).
|
|
740
759
|
for name, cf in config_fields.items():
|
|
760
|
+
if name in flag_params:
|
|
761
|
+
continue
|
|
741
762
|
if not cf.required:
|
|
742
763
|
_nested_set(data, name, cf.default)
|
|
743
764
|
else:
|
|
@@ -784,6 +805,43 @@ def _split_escaped(value: str, sep: str) -> list[str]:
|
|
|
784
805
|
return parts
|
|
785
806
|
|
|
786
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
|
+
|
|
787
845
|
def _find_duplicate(values: list) -> object | None:
|
|
788
846
|
"""Return the first duplicate value in the list, or None if all unique."""
|
|
789
847
|
seen: set = set()
|
|
@@ -1395,6 +1453,11 @@ class Flag:
|
|
|
1395
1453
|
validate: Callable | None = None
|
|
1396
1454
|
repeatable: bool = False
|
|
1397
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
|
|
1398
1461
|
# Compound type fields (set by __post_init__, not by caller)
|
|
1399
1462
|
compound: str = "scalar" # "scalar", "list", or "dict"
|
|
1400
1463
|
item_type: type | None = None # for list[T]: the T
|
|
@@ -1478,6 +1541,13 @@ class Flag:
|
|
|
1478
1541
|
raise ValueError(f'Flag "{self.name}": unique requires repeatable=True')
|
|
1479
1542
|
if isinstance(self.unique, _MissingSentinel) and not self.repeatable:
|
|
1480
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
|
+
)
|
|
1481
1551
|
# Validate env_separator
|
|
1482
1552
|
if self.compound == "dict":
|
|
1483
1553
|
# Dict flags use JSON for env vars, not env_separator
|
|
@@ -1904,10 +1974,10 @@ class Group:
|
|
|
1904
1974
|
hidden: bool = False,
|
|
1905
1975
|
interactive: bool = False,
|
|
1906
1976
|
config_fields: list[str] | None = None,
|
|
1907
|
-
) -> Callable:
|
|
1977
|
+
) -> Callable[[F], F]:
|
|
1908
1978
|
"""Decorator to register a command within this group."""
|
|
1909
1979
|
|
|
1910
|
-
def decorator(func:
|
|
1980
|
+
def decorator(func: F) -> F:
|
|
1911
1981
|
if name in self._groups:
|
|
1912
1982
|
raise ValueError(
|
|
1913
1983
|
f'command "{name}" collides with an existing group'
|
|
@@ -2137,6 +2207,18 @@ class App:
|
|
|
2137
2207
|
"""Return the resolved config file path for this app."""
|
|
2138
2208
|
return _config_path(self.name, override=self.config_path, config_format=self.config_format)
|
|
2139
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
|
+
|
|
2140
2222
|
def config_field(
|
|
2141
2223
|
self,
|
|
2142
2224
|
name: str,
|
|
@@ -2172,6 +2254,13 @@ class App:
|
|
|
2172
2254
|
f'config field name "{name}" conflicts with framework field'
|
|
2173
2255
|
)
|
|
2174
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)
|
|
2175
2264
|
self._config_fields[name] = cf
|
|
2176
2265
|
return cf
|
|
2177
2266
|
|
|
@@ -2202,9 +2291,9 @@ class App:
|
|
|
2202
2291
|
self._framework_fields[name] = cf
|
|
2203
2292
|
return cf
|
|
2204
2293
|
|
|
2205
|
-
def check(self, name: str):
|
|
2294
|
+
def check(self, name: str) -> Callable[[F], F]:
|
|
2206
2295
|
"""Decorator to register a check implementation."""
|
|
2207
|
-
def decorator(fn):
|
|
2296
|
+
def decorator(fn: F) -> F:
|
|
2208
2297
|
if not self._checks_enabled:
|
|
2209
2298
|
raise ValueError(
|
|
2210
2299
|
f'cannot register check "{name}": '
|
|
@@ -2508,10 +2597,10 @@ class App:
|
|
|
2508
2597
|
hidden: bool = False,
|
|
2509
2598
|
interactive: bool = False,
|
|
2510
2599
|
config_fields: list[str] | None = None,
|
|
2511
|
-
) -> Callable:
|
|
2600
|
+
) -> Callable[[F], F]:
|
|
2512
2601
|
"""Decorator to register a top-level command."""
|
|
2513
2602
|
|
|
2514
|
-
def decorator(func:
|
|
2603
|
+
def decorator(func: F) -> F:
|
|
2515
2604
|
cmd = _build_and_validate_command(
|
|
2516
2605
|
name,
|
|
2517
2606
|
help=help,
|
|
@@ -2593,6 +2682,22 @@ class App:
|
|
|
2593
2682
|
_collect_from_group(grp)
|
|
2594
2683
|
return flags
|
|
2595
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
|
+
|
|
2596
2701
|
def _register_config_group(self) -> None:
|
|
2597
2702
|
"""Register the auto-generated 'config' command group."""
|
|
2598
2703
|
config_grp = Group(
|
|
@@ -2628,14 +2733,18 @@ class App:
|
|
|
2628
2733
|
use_json = _kw.get("json", False)
|
|
2629
2734
|
config_data = app_ref._config_data
|
|
2630
2735
|
all_flags = app_ref._collect_all_flags()
|
|
2736
|
+
colliding = app_ref._colliding_config_fields()
|
|
2631
2737
|
if use_json:
|
|
2632
2738
|
result = {}
|
|
2633
2739
|
for f in all_flags:
|
|
2634
2740
|
param = _flag_param_name(f.name)
|
|
2635
2741
|
value, source = _resolve_flag_show_source(f, config_data)
|
|
2636
2742
|
result[param] = {"value": value, "source": source}
|
|
2637
|
-
# 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).
|
|
2638
2745
|
for cf_name, cf in app_ref._config_fields.items():
|
|
2746
|
+
if cf_name in colliding:
|
|
2747
|
+
continue
|
|
2639
2748
|
found, value = _nested_get(config_data, cf_name)
|
|
2640
2749
|
if found:
|
|
2641
2750
|
source = "config"
|
|
@@ -2661,12 +2770,22 @@ class App:
|
|
|
2661
2770
|
for f in all_flags:
|
|
2662
2771
|
param = _flag_param_name(f.name)
|
|
2663
2772
|
value, source = _resolve_flag_show_source(f, config_data)
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
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:
|
|
2667
2786
|
print()
|
|
2668
2787
|
print("Config fields:")
|
|
2669
|
-
for cf_name, cf in
|
|
2788
|
+
for cf_name, cf in non_colliding_fields.items():
|
|
2670
2789
|
found, value = _nested_get(config_data, cf_name)
|
|
2671
2790
|
if found:
|
|
2672
2791
|
source = "config"
|
|
@@ -3576,14 +3695,28 @@ class App:
|
|
|
3576
3695
|
param = _flag_param_name(f.name)
|
|
3577
3696
|
if param not in self._config_data:
|
|
3578
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
|
+
)
|
|
3579
3704
|
if f.name in cli_set:
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
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
|
|
3587
3720
|
try:
|
|
3588
3721
|
coerced = _coerce_config_value(self._config_data[param], f)
|
|
3589
3722
|
except ValueError as e:
|
|
@@ -4734,15 +4867,29 @@ def _parse_command(
|
|
|
4734
4867
|
param = _flag_param_name(f.name)
|
|
4735
4868
|
if param not in config_data:
|
|
4736
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
|
+
)
|
|
4737
4876
|
if f.name in cli_set:
|
|
4738
|
-
# Flag set by CLI or env, config also has a value
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
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
|
|
4746
4893
|
try:
|
|
4747
4894
|
coerced = _coerce_config_value(config_data[param], f)
|
|
4748
4895
|
except ValueError as e:
|
|
@@ -4920,6 +5067,15 @@ def _build_and_validate_command(
|
|
|
4920
5067
|
f'command "{name}": flag "{f.name}" collides with a global flag'
|
|
4921
5068
|
)
|
|
4922
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
|
+
|
|
4923
5079
|
# Validate: no duplicate arg names
|
|
4924
5080
|
seen_arg_names: set[str] = set()
|
|
4925
5081
|
for a in all_args:
|
|
@@ -5114,10 +5270,11 @@ def flag(
|
|
|
5114
5270
|
validate: Callable | None = None,
|
|
5115
5271
|
repeatable: bool = False,
|
|
5116
5272
|
unique: object = _MISSING,
|
|
5117
|
-
|
|
5273
|
+
conflict_mode: object = _MISSING,
|
|
5274
|
+
) -> Callable[[F], F]:
|
|
5118
5275
|
"""Module-level decorator to attach a Flag to a command handler."""
|
|
5119
5276
|
|
|
5120
|
-
def decorator(func:
|
|
5277
|
+
def decorator(func: F) -> F:
|
|
5121
5278
|
f = Flag(
|
|
5122
5279
|
name=name,
|
|
5123
5280
|
short=short,
|
|
@@ -5132,6 +5289,7 @@ def flag(
|
|
|
5132
5289
|
validate=validate,
|
|
5133
5290
|
repeatable=repeatable,
|
|
5134
5291
|
unique=unique,
|
|
5292
|
+
conflict_mode=conflict_mode,
|
|
5135
5293
|
)
|
|
5136
5294
|
if not hasattr(func, "_strictcli_flags"):
|
|
5137
5295
|
func._strictcli_flags = []
|
|
@@ -5150,10 +5308,10 @@ def arg(
|
|
|
5150
5308
|
variadic: bool = False,
|
|
5151
5309
|
type: type = str,
|
|
5152
5310
|
choices: list | None = None,
|
|
5153
|
-
) -> Callable:
|
|
5311
|
+
) -> Callable[[F], F]:
|
|
5154
5312
|
"""Module-level decorator to attach an Arg to a command handler."""
|
|
5155
5313
|
|
|
5156
|
-
def decorator(func:
|
|
5314
|
+
def decorator(func: F) -> F:
|
|
5157
5315
|
a = Arg(
|
|
5158
5316
|
name=name, help=help, required=required, default=default,
|
|
5159
5317
|
variadic=variadic, type=type, choices=choices,
|
|
@@ -5967,6 +6125,12 @@ def _serialize_flag(f: Flag) -> dict:
|
|
|
5967
6125
|
d["repeatable"] = f.repeatable
|
|
5968
6126
|
if f.unique is True:
|
|
5969
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
|
|
5970
6134
|
if f.env_separator is not None:
|
|
5971
6135
|
d["env_separator"] = f.env_separator
|
|
5972
6136
|
negatable = f.negatable if f.type is bool and f.compound == "scalar" else None
|
|
@@ -6183,17 +6347,20 @@ def _collect_config_field_bindings_from_group(
|
|
|
6183
6347
|
_collect_config_field_bindings_from_group(sub, bindings, group_path)
|
|
6184
6348
|
|
|
6185
6349
|
|
|
6186
|
-
def
|
|
6187
|
-
"""
|
|
6350
|
+
def _dump_schema_core(app: App) -> dict:
|
|
6351
|
+
"""Build the full schema dict, excluding ``project_id``.
|
|
6188
6352
|
|
|
6189
|
-
|
|
6190
|
-
|
|
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.
|
|
6191
6360
|
"""
|
|
6192
|
-
project_id = _read_project_id()
|
|
6193
6361
|
schema: dict = {
|
|
6194
6362
|
"schema_version": 1,
|
|
6195
6363
|
"defaults": _build_schema_defaults(),
|
|
6196
|
-
"project_id": project_id,
|
|
6197
6364
|
"name": app.name,
|
|
6198
6365
|
"version": app.version,
|
|
6199
6366
|
"help": app.help,
|
|
@@ -6257,6 +6424,23 @@ def _dump_schema(app: App) -> dict:
|
|
|
6257
6424
|
return schema
|
|
6258
6425
|
|
|
6259
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
|
+
|
|
6260
6444
|
def _check_schema_project_id(file_path: str, new_project_id: str) -> None:
|
|
6261
6445
|
"""Verify that an existing schema file belongs to the same project.
|
|
6262
6446
|
|
|
File without changes
|
|
@@ -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
|