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.
Files changed (81) hide show
  1. {strictcli-0.25.0 → strictcli-0.27.0}/.rlsbl/config.json +2 -1
  2. {strictcli-0.25.0 → strictcli-0.27.0}/PKG-INFO +1 -1
  3. {strictcli-0.25.0 → strictcli-0.27.0}/package-lock.json +2 -2
  4. {strictcli-0.25.0 → strictcli-0.27.0}/package.json +1 -1
  5. {strictcli-0.25.0 → strictcli-0.27.0}/pyproject.toml +1 -1
  6. {strictcli-0.25.0 → strictcli-0.27.0}/strictcli/__init__.py +225 -41
  7. strictcli-0.27.0/strictcli/py.typed +0 -0
  8. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_config.py +113 -3
  9. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_config_fields.py +104 -0
  10. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_dump_schema.py +55 -0
  11. {strictcli-0.25.0 → strictcli-0.27.0}/uv.lock +1 -1
  12. {strictcli-0.25.0 → strictcli-0.27.0}/.claude/settings.json +0 -0
  13. {strictcli-0.25.0 → strictcli-0.27.0}/.github/workflows/ci-pypi.yml +0 -0
  14. {strictcli-0.25.0 → strictcli-0.27.0}/.github/workflows/ci.yml +0 -0
  15. {strictcli-0.25.0 → strictcli-0.27.0}/.github/workflows/publish.yml +0 -0
  16. {strictcli-0.25.0 → strictcli-0.27.0}/.gitignore +0 -0
  17. {strictcli-0.25.0 → strictcli-0.27.0}/.npmignore +0 -0
  18. {strictcli-0.25.0 → strictcli-0.27.0}/.rlsbl/hashes.json +0 -0
  19. {strictcli-0.25.0 → strictcli-0.27.0}/.rlsbl/managed-files.json +0 -0
  20. {strictcli-0.25.0 → strictcli-0.27.0}/.strictcli/schema.json +0 -0
  21. {strictcli-0.25.0 → strictcli-0.27.0}/CLAUDE.md +0 -0
  22. {strictcli-0.25.0 → strictcli-0.27.0}/LICENSE +0 -0
  23. {strictcli-0.25.0 → strictcli-0.27.0}/README.md +0 -0
  24. {strictcli-0.25.0 → strictcli-0.27.0}/index.js +0 -0
  25. {strictcli-0.25.0 → strictcli-0.27.0}/postinstall.js +0 -0
  26. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_arg_default.py +0 -0
  27. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_arg_default_validation.py +0 -0
  28. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_at_prefix.py +0 -0
  29. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_auto_version.py +0 -0
  30. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_call.py +0 -0
  31. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_command.py +0 -0
  32. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_discovery.py +0 -0
  33. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_public_api.py +0 -0
  34. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_runner.py +0 -0
  35. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_schema.py +0 -0
  36. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_check_types.py +0 -0
  37. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_choices.py +0 -0
  38. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_choices_none.py +0 -0
  39. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_command_help_suggestion.py +0 -0
  40. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_command_tags.py +0 -0
  41. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_compound_types.py +0 -0
  42. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_config_file_path.py +0 -0
  43. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_config_set_bugs.py +0 -0
  44. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_context.py +0 -0
  45. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_deep_nesting.py +0 -0
  46. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_dependencies.py +0 -0
  47. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_deprecated.py +0 -0
  48. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_e2e.py +0 -0
  49. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_env.py +0 -0
  50. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_exit_codes.py +0 -0
  51. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_flag_sets.py +0 -0
  52. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_float_type.py +0 -0
  53. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_global_flags.py +0 -0
  54. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_help.py +0 -0
  55. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_hermetic.py +0 -0
  56. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_int_type.py +0 -0
  57. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_invoke.py +0 -0
  58. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_keyword_flags.py +0 -0
  59. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_mcp.py +0 -0
  60. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_mutex.py +0 -0
  61. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_nesting.py +0 -0
  62. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_parser.py +0 -0
  63. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_passthrough.py +0 -0
  64. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_provenance.py +0 -0
  65. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_provenance_phase2.py +0 -0
  66. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_registration.py +0 -0
  67. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_repeatable.py +0 -0
  68. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_reserved_global_flags.py +0 -0
  69. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_tagdsl.py +0 -0
  70. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_toml_loading.py +0 -0
  71. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_tool_export.py +0 -0
  72. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_typed_args.py +0 -0
  73. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_unique.py +0 -0
  74. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_utilities.py +0 -0
  75. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_validate.py +0 -0
  76. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_variadic.py +0 -0
  77. {strictcli-0.25.0 → strictcli-0.27.0}/tests/test_visibility.py +0 -0
  78. {strictcli-0.25.0 → strictcli-0.27.0}/todo/.defer/deferred.md +0 -0
  79. {strictcli-0.25.0 → strictcli-0.27.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  80. {strictcli-0.25.0 → strictcli-0.27.0}/todo/.done/original-idea.md +0 -0
  81. {strictcli-0.25.0 → strictcli-0.27.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -14,5 +14,6 @@
14
14
  "type": "npm",
15
15
  "local": false
16
16
  }
17
- }
17
+ },
18
+ "coverage_unit": "commit"
18
19
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.25.0
3
+ Version: 0.27.0
4
4
  Summary: A strict, zero-dependency CLI framework for Python
5
5
  Project-URL: Homepage, https://github.com/smm-h/strictcli
6
6
  Project-URL: Repository, https://github.com/smm-h/strictcli
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.25.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.25.0",
9
+ "version": "0.27.0",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "description": "A strict, zero-dependency CLI framework for Python (npm wrapper)",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.25.0"
7
+ version = "0.27.0"
8
8
  description = "A strict, zero-dependency CLI framework for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.25.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
- lines.append(f"# {f.help}")
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: Callable) -> Callable:
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: Callable) -> Callable:
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
- print(f"{param} = {_format_config_value(value)} (source: {source})")
2665
- # Include config fields in plain output
2666
- if app_ref._config_fields:
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 app_ref._config_fields.items():
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
- if self.config_conflict_mode == "error":
3581
- existing_source = global_sources.get(param, "cli")
3582
- raise _ParseError(
3583
- f"flag '{f.name}' set in both "
3584
- f"{existing_source} and config; remove one"
3585
- )
3586
- continue # cli-wins: skip config value
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
- if conflict_mode == "error":
4740
- existing_source = "env" if f.name in env_names else "cli"
4741
- raise _ParseError(
4742
- f"flag '{f.name}' set in both "
4743
- f"{existing_source} and config; remove one"
4744
- )
4745
- continue # cli-wins: skip config value
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
- ) -> Callable:
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: Callable) -> Callable:
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: Callable) -> Callable:
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 _dump_schema(app: App) -> dict:
6187
- """Produce a JSON-serializable dict representing the app's command tree.
6350
+ def _dump_schema_core(app: App) -> dict:
6351
+ """Build the full schema dict, excluding ``project_id``.
6188
6352
 
6189
- Fields whose values match the schema defaults are omitted.
6190
- The top-level ``defaults`` key documents what each missing field means.
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": true}')
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)
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.25.0"
235
+ version = "0.27.0"
236
236
  source = { editable = "." }
237
237
 
238
238
  [package.dev-dependencies]
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