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.
Files changed (81) hide show
  1. {strictcli-0.26.0 → strictcli-0.27.0}/PKG-INFO +1 -1
  2. {strictcli-0.26.0 → strictcli-0.27.0}/package-lock.json +2 -2
  3. {strictcli-0.26.0 → strictcli-0.27.0}/package.json +1 -1
  4. {strictcli-0.26.0 → strictcli-0.27.0}/pyproject.toml +1 -1
  5. {strictcli-0.26.0 → strictcli-0.27.0}/strictcli/__init__.py +211 -30
  6. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_config.py +113 -3
  7. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_config_fields.py +104 -0
  8. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_dump_schema.py +55 -0
  9. {strictcli-0.26.0 → strictcli-0.27.0}/uv.lock +1 -1
  10. {strictcli-0.26.0 → strictcli-0.27.0}/.claude/settings.json +0 -0
  11. {strictcli-0.26.0 → strictcli-0.27.0}/.github/workflows/ci-pypi.yml +0 -0
  12. {strictcli-0.26.0 → strictcli-0.27.0}/.github/workflows/ci.yml +0 -0
  13. {strictcli-0.26.0 → strictcli-0.27.0}/.github/workflows/publish.yml +0 -0
  14. {strictcli-0.26.0 → strictcli-0.27.0}/.gitignore +0 -0
  15. {strictcli-0.26.0 → strictcli-0.27.0}/.npmignore +0 -0
  16. {strictcli-0.26.0 → strictcli-0.27.0}/.rlsbl/config.json +0 -0
  17. {strictcli-0.26.0 → strictcli-0.27.0}/.rlsbl/hashes.json +0 -0
  18. {strictcli-0.26.0 → strictcli-0.27.0}/.rlsbl/managed-files.json +0 -0
  19. {strictcli-0.26.0 → strictcli-0.27.0}/.strictcli/schema.json +0 -0
  20. {strictcli-0.26.0 → strictcli-0.27.0}/CLAUDE.md +0 -0
  21. {strictcli-0.26.0 → strictcli-0.27.0}/LICENSE +0 -0
  22. {strictcli-0.26.0 → strictcli-0.27.0}/README.md +0 -0
  23. {strictcli-0.26.0 → strictcli-0.27.0}/index.js +0 -0
  24. {strictcli-0.26.0 → strictcli-0.27.0}/postinstall.js +0 -0
  25. {strictcli-0.26.0 → strictcli-0.27.0}/strictcli/py.typed +0 -0
  26. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_arg_default.py +0 -0
  27. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_arg_default_validation.py +0 -0
  28. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_at_prefix.py +0 -0
  29. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_auto_version.py +0 -0
  30. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_call.py +0 -0
  31. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_command.py +0 -0
  32. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_discovery.py +0 -0
  33. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_public_api.py +0 -0
  34. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_runner.py +0 -0
  35. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_schema.py +0 -0
  36. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_check_types.py +0 -0
  37. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_choices.py +0 -0
  38. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_choices_none.py +0 -0
  39. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_command_help_suggestion.py +0 -0
  40. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_command_tags.py +0 -0
  41. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_compound_types.py +0 -0
  42. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_config_file_path.py +0 -0
  43. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_config_set_bugs.py +0 -0
  44. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_context.py +0 -0
  45. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_deep_nesting.py +0 -0
  46. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_dependencies.py +0 -0
  47. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_deprecated.py +0 -0
  48. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_e2e.py +0 -0
  49. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_env.py +0 -0
  50. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_exit_codes.py +0 -0
  51. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_flag_sets.py +0 -0
  52. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_float_type.py +0 -0
  53. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_global_flags.py +0 -0
  54. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_help.py +0 -0
  55. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_hermetic.py +0 -0
  56. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_int_type.py +0 -0
  57. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_invoke.py +0 -0
  58. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_keyword_flags.py +0 -0
  59. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_mcp.py +0 -0
  60. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_mutex.py +0 -0
  61. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_nesting.py +0 -0
  62. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_parser.py +0 -0
  63. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_passthrough.py +0 -0
  64. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_provenance.py +0 -0
  65. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_provenance_phase2.py +0 -0
  66. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_registration.py +0 -0
  67. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_repeatable.py +0 -0
  68. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_reserved_global_flags.py +0 -0
  69. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_tagdsl.py +0 -0
  70. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_toml_loading.py +0 -0
  71. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_tool_export.py +0 -0
  72. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_typed_args.py +0 -0
  73. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_unique.py +0 -0
  74. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_utilities.py +0 -0
  75. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_validate.py +0 -0
  76. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_variadic.py +0 -0
  77. {strictcli-0.26.0 → strictcli-0.27.0}/tests/test_visibility.py +0 -0
  78. {strictcli-0.26.0 → strictcli-0.27.0}/todo/.defer/deferred.md +0 -0
  79. {strictcli-0.26.0 → strictcli-0.27.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  80. {strictcli-0.26.0 → strictcli-0.27.0}/todo/.done/original-idea.md +0 -0
  81. {strictcli-0.26.0 → strictcli-0.27.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.26.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.26.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.26.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.26.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.26.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.26.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
- 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)
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
- print(f"{param} = {_format_config_value(value)} (source: {source})")
2668
- # Include config fields in plain output
2669
- 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:
2670
2786
  print()
2671
2787
  print("Config fields:")
2672
- for cf_name, cf in app_ref._config_fields.items():
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
- if self.config_conflict_mode == "error":
3584
- existing_source = global_sources.get(param, "cli")
3585
- raise _ParseError(
3586
- f"flag '{f.name}' set in both "
3587
- f"{existing_source} and config; remove one"
3588
- )
3589
- 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
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
- if conflict_mode == "error":
4743
- existing_source = "env" if f.name in env_names else "cli"
4744
- raise _ParseError(
4745
- f"flag '{f.name}' set in both "
4746
- f"{existing_source} and config; remove one"
4747
- )
4748
- 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
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 _dump_schema(app: App) -> dict:
6190
- """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``.
6191
6352
 
6192
- Fields whose values match the schema defaults are omitted.
6193
- 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.
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": 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.26.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