strictcli 0.23.0__tar.gz → 0.24.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 (76) hide show
  1. {strictcli-0.23.0 → strictcli-0.24.0}/PKG-INFO +1 -1
  2. {strictcli-0.23.0 → strictcli-0.24.0}/package-lock.json +2 -2
  3. {strictcli-0.23.0 → strictcli-0.24.0}/package.json +1 -1
  4. {strictcli-0.23.0 → strictcli-0.24.0}/pyproject.toml +1 -1
  5. {strictcli-0.23.0 → strictcli-0.24.0}/strictcli/__init__.py +99 -78
  6. strictcli-0.24.0/tests/test_arg_default_validation.py +79 -0
  7. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_check_runner.py +64 -2
  8. strictcli-0.24.0/tests/test_choices_none.py +163 -0
  9. {strictcli-0.23.0 → strictcli-0.24.0}/uv.lock +1 -1
  10. {strictcli-0.23.0 → strictcli-0.24.0}/.claude/settings.json +0 -0
  11. {strictcli-0.23.0 → strictcli-0.24.0}/.github/workflows/ci.yml +0 -0
  12. {strictcli-0.23.0 → strictcli-0.24.0}/.github/workflows/publish.yml +0 -0
  13. {strictcli-0.23.0 → strictcli-0.24.0}/.gitignore +0 -0
  14. {strictcli-0.23.0 → strictcli-0.24.0}/.npmignore +0 -0
  15. {strictcli-0.23.0 → strictcli-0.24.0}/.rlsbl/config.json +0 -0
  16. {strictcli-0.23.0 → strictcli-0.24.0}/.rlsbl/hashes.json +0 -0
  17. {strictcli-0.23.0 → strictcli-0.24.0}/.rlsbl/version +0 -0
  18. {strictcli-0.23.0 → strictcli-0.24.0}/.strictcli/schema.json +0 -0
  19. {strictcli-0.23.0 → strictcli-0.24.0}/CHANGELOG.md +0 -0
  20. {strictcli-0.23.0 → strictcli-0.24.0}/CLAUDE.md +0 -0
  21. {strictcli-0.23.0 → strictcli-0.24.0}/LICENSE +0 -0
  22. {strictcli-0.23.0 → strictcli-0.24.0}/README.md +0 -0
  23. {strictcli-0.23.0 → strictcli-0.24.0}/index.js +0 -0
  24. {strictcli-0.23.0 → strictcli-0.24.0}/postinstall.js +0 -0
  25. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_arg_default.py +0 -0
  26. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_at_prefix.py +0 -0
  27. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_auto_version.py +0 -0
  28. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_call.py +0 -0
  29. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_check_command.py +0 -0
  30. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_check_discovery.py +0 -0
  31. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_check_public_api.py +0 -0
  32. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_check_schema.py +0 -0
  33. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_check_types.py +0 -0
  34. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_choices.py +0 -0
  35. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_command_help_suggestion.py +0 -0
  36. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_command_tags.py +0 -0
  37. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_compound_types.py +0 -0
  38. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_config.py +0 -0
  39. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_config_fields.py +0 -0
  40. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_config_file_path.py +0 -0
  41. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_config_set_bugs.py +0 -0
  42. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_context.py +0 -0
  43. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_deep_nesting.py +0 -0
  44. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_dependencies.py +0 -0
  45. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_deprecated.py +0 -0
  46. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_dump_schema.py +0 -0
  47. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_e2e.py +0 -0
  48. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_env.py +0 -0
  49. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_exit_codes.py +0 -0
  50. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_flag_sets.py +0 -0
  51. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_float_type.py +0 -0
  52. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_global_flags.py +0 -0
  53. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_help.py +0 -0
  54. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_int_type.py +0 -0
  55. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_invoke.py +0 -0
  56. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_keyword_flags.py +0 -0
  57. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_mcp.py +0 -0
  58. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_mutex.py +0 -0
  59. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_nesting.py +0 -0
  60. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_parser.py +0 -0
  61. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_passthrough.py +0 -0
  62. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_registration.py +0 -0
  63. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_repeatable.py +0 -0
  64. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_tagdsl.py +0 -0
  65. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_toml_loading.py +0 -0
  66. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_tool_export.py +0 -0
  67. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_typed_args.py +0 -0
  68. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_unique.py +0 -0
  69. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_utilities.py +0 -0
  70. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_validate.py +0 -0
  71. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_variadic.py +0 -0
  72. {strictcli-0.23.0 → strictcli-0.24.0}/tests/test_visibility.py +0 -0
  73. {strictcli-0.23.0 → strictcli-0.24.0}/todo/.defer/deferred.md +0 -0
  74. {strictcli-0.23.0 → strictcli-0.24.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  75. {strictcli-0.23.0 → strictcli-0.24.0}/todo/.done/original-idea.md +0 -0
  76. {strictcli-0.23.0 → strictcli-0.24.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.23.0
3
+ Version: 0.24.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.23.0",
3
+ "version": "0.24.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "strictcli",
9
- "version": "0.23.0",
9
+ "version": "0.24.0",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.23.0",
3
+ "version": "0.24.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.23.0"
7
+ version = "0.24.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.23.0"
5
+ __version__ = "0.24.0"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
@@ -1484,7 +1484,38 @@ class Arg:
1484
1484
  )
1485
1485
  # Validate default type matches declared type
1486
1486
  if not isinstance(self.default, _MissingSentinel) and self.default is not None:
1487
- if self.type is int:
1487
+ if self.compound == "list":
1488
+ # self.type was normalized to the item type above; the
1489
+ # default itself must be a list of that item type.
1490
+ if not isinstance(self.default, list):
1491
+ raise ValueError(
1492
+ f'Arg "{self.name}": list arg default must be a list'
1493
+ )
1494
+ if len(self.default) == 0:
1495
+ raise ValueError(
1496
+ f'Arg "{self.name}": explicit empty default is '
1497
+ f"redundant for list args, omit the default"
1498
+ )
1499
+ type_name = {str: "str", int: "int", float: "float"}[self.type]
1500
+ for i, elem in enumerate(self.default):
1501
+ if self.type is str:
1502
+ valid = isinstance(elem, str)
1503
+ elif self.type is int:
1504
+ valid = isinstance(elem, int) and not isinstance(elem, bool)
1505
+ else: # float
1506
+ valid = (
1507
+ isinstance(elem, (int, float))
1508
+ and not isinstance(elem, bool)
1509
+ )
1510
+ if valid and isinstance(elem, int):
1511
+ # Auto-coerce int to float, mirroring list flag defaults
1512
+ self.default[i] = float(elem)
1513
+ if not valid:
1514
+ raise ValueError(
1515
+ f'Arg "{self.name}": default element {i} '
1516
+ f"is not of type {type_name}"
1517
+ )
1518
+ elif self.type is int:
1488
1519
  if not isinstance(self.default, int) or isinstance(self.default, bool):
1489
1520
  raise ValueError(
1490
1521
  f'Arg "{self.name}": type=int requires an int default, '
@@ -1502,6 +1533,12 @@ class Arg:
1502
1533
  f'Arg "{self.name}": type=bool requires a bool default, '
1503
1534
  f"got {type(self.default).__name__!r}"
1504
1535
  )
1536
+ elif self.type is str:
1537
+ if not isinstance(self.default, str):
1538
+ raise ValueError(
1539
+ f'Arg "{self.name}": type=str requires a str default, '
1540
+ f"got {type(self.default).__name__!r}"
1541
+ )
1505
1542
  # Validate default is in choices
1506
1543
  if self.choices is not None and not isinstance(self.default, _MissingSentinel) and self.default is not None:
1507
1544
  if self.default not in self.choices:
@@ -3185,21 +3222,8 @@ class App:
3185
3222
 
3186
3223
  # Validate choices for global flags
3187
3224
  for f in self._global_flags:
3188
- if f.choices is not None and f.name in cli_set:
3189
- if f.repeatable:
3190
- for val in cli_set[f.name]:
3191
- if val not in f.choices:
3192
- choices_str = ", ".join(str(c) for c in f.choices)
3193
- raise _ParseError(
3194
- f"--{f.name}: invalid value '{val}', must be one of: {choices_str}"
3195
- )
3196
- else:
3197
- val = cli_set[f.name]
3198
- if val not in f.choices:
3199
- choices_str = ", ".join(str(c) for c in f.choices)
3200
- raise _ParseError(
3201
- f"--{f.name}: invalid value '{val}', must be one of: {choices_str}"
3202
- )
3225
+ if f.name in cli_set:
3226
+ _validate_choices(f.name, cli_set[f.name], f.repeatable, f.choices)
3203
3227
 
3204
3228
  return cli_set, remaining
3205
3229
 
@@ -3754,6 +3778,39 @@ def _tokens_contain_help(tokens: list[str]) -> bool:
3754
3778
  return False
3755
3779
 
3756
3780
 
3781
+ def _validate_choices(
3782
+ name: str,
3783
+ val: object,
3784
+ repeatable: bool,
3785
+ choices: list | None,
3786
+ *,
3787
+ is_arg: bool = False,
3788
+ ) -> None:
3789
+ """Validate a resolved flag or arg value against its choices list.
3790
+
3791
+ Raises _ParseError on an invalid value. is_arg selects the message prefix
3792
+ ("argument 'name':" instead of "--name:"); the two f-strings are kept as
3793
+ full literals so conformance/check_error_parity.py can extract them.
3794
+ A None value is exempt from validation: None only arises when the flag or
3795
+ arg was not passed (an unset mutex flag, or default=None on an arg) -- a
3796
+ CLI-supplied value is never None.
3797
+ """
3798
+ if choices is None or val is None:
3799
+ return
3800
+ vals = val if repeatable else [val]
3801
+ for v in vals:
3802
+ if v not in choices:
3803
+ choices_str = ", ".join(str(c) for c in choices)
3804
+ if is_arg:
3805
+ raise _ParseError(
3806
+ f"argument '{name}': invalid value '{v}', "
3807
+ f"must be one of: {choices_str}"
3808
+ )
3809
+ raise _ParseError(
3810
+ f"--{name}: invalid value '{v}', must be one of: {choices_str}"
3811
+ )
3812
+
3813
+
3757
3814
  def _validate_and_build_kwargs(
3758
3815
  cmd: Command,
3759
3816
  cli_set: dict[str, object],
@@ -3849,21 +3906,8 @@ def _validate_and_build_kwargs(
3849
3906
 
3850
3907
  # Step 5.5: validate choices
3851
3908
  for f in cmd.flags:
3852
- if f.choices is not None and f.name in cli_set:
3853
- if f.repeatable:
3854
- for val in cli_set[f.name]:
3855
- if val not in f.choices:
3856
- choices_str = ", ".join(str(c) for c in f.choices)
3857
- raise _ParseError(
3858
- f"--{f.name}: invalid value '{val}', must be one of: {choices_str}"
3859
- )
3860
- else:
3861
- val = cli_set[f.name]
3862
- if val not in f.choices:
3863
- choices_str = ", ".join(str(c) for c in f.choices)
3864
- raise _ParseError(
3865
- f"--{f.name}: invalid value '{val}', must be one of: {choices_str}"
3866
- )
3909
+ if f.name in cli_set:
3910
+ _validate_choices(f.name, cli_set[f.name], f.repeatable, f.choices)
3867
3911
 
3868
3912
  # Step 5.6: custom validation
3869
3913
  for f in cmd.flags:
@@ -3874,7 +3918,9 @@ def _validate_and_build_kwargs(
3874
3918
  f.validate(val)
3875
3919
  except ValueError as e:
3876
3920
  raise _ParseError(f"--{f.name}: {e}")
3877
- else:
3921
+ elif cli_set[f.name] is not None:
3922
+ # None means the flag was not passed (an unset mutex flag) --
3923
+ # there is no value to validate.
3878
3924
  try:
3879
3925
  f.validate(cli_set[f.name])
3880
3926
  except ValueError as e:
@@ -3904,23 +3950,10 @@ def _validate_and_build_kwargs(
3904
3950
 
3905
3951
  # Step 6.5: validate arg choices
3906
3952
  for a in cmd.args:
3907
- if a.choices is not None and a.name in arg_values:
3908
- val = arg_values[a.name]
3909
- if a.variadic:
3910
- for v in val:
3911
- if v not in a.choices:
3912
- choices_str = ", ".join(str(c) for c in a.choices)
3913
- raise _ParseError(
3914
- f"argument '{a.name}': invalid value '{v}', "
3915
- f"must be one of: {choices_str}"
3916
- )
3917
- else:
3918
- if val not in a.choices:
3919
- choices_str = ", ".join(str(c) for c in a.choices)
3920
- raise _ParseError(
3921
- f"argument '{a.name}': invalid value '{val}', "
3922
- f"must be one of: {choices_str}"
3923
- )
3953
+ if a.name in arg_values:
3954
+ _validate_choices(
3955
+ a.name, arg_values[a.name], a.variadic, a.choices, is_arg=True,
3956
+ )
3924
3957
 
3925
3958
  # Step 7: build kwargs dict (command flags only)
3926
3959
  kwargs: dict[str, object] = {}
@@ -5274,15 +5307,22 @@ def _run_checks(
5274
5307
  warn with ignore_warnings=True), 1 otherwise.
5275
5308
  """
5276
5309
  results: list[tuple[str, CheckResult]] = []
5310
+ # Checks whose dependents should be cascade-skipped: only fail or
5311
+ # cascade-skip qualifies. A warn satisfies the dependency (dependents
5312
+ # still run) regardless of ignore_warnings -- it only affects the exit
5313
+ # code. Explicit skip from an impl is not a failure either.
5277
5314
  failed_checks: set[str] = set()
5278
5315
  exit_code = 0
5279
5316
 
5280
- # Build reverse dependency map for this execution set
5281
- dependents_of: dict[str, set[str]] = {name: set() for name in check_names}
5282
- for name in check_names:
5283
- for dep in check_defs[name].depends_on:
5284
- if dep in dependents_of:
5285
- dependents_of[dep].add(name)
5317
+ def record(name: str, result: CheckResult) -> None:
5318
+ nonlocal exit_code
5319
+ if result.status == "fail":
5320
+ failed_checks.add(name)
5321
+ exit_code = 1
5322
+ elif result.status == "warn":
5323
+ if not ignore_warnings:
5324
+ exit_code = 1
5325
+ # "skip" from an impl or adapter: no cascade, no exit code change.
5286
5326
 
5287
5327
  for name in check_names:
5288
5328
  cdef = check_defs[name]
@@ -5309,33 +5349,14 @@ def _run_checks(
5309
5349
  if cdef.scope and scope_adapter is not None:
5310
5350
  adapted = scope_adapter(context, cdef.scope)
5311
5351
  if isinstance(adapted, CheckResult):
5312
- result = adapted
5313
- results.append((name, result))
5314
- if result.status == "fail":
5315
- failed_checks.add(name)
5316
- exit_code = 1
5317
- elif result.status == "warn":
5318
- if not ignore_warnings:
5319
- failed_checks.add(name)
5320
- exit_code = 1
5321
- elif result.status == "skip":
5322
- pass
5352
+ results.append((name, adapted))
5353
+ record(name, adapted)
5323
5354
  continue
5324
5355
  check_context = adapted
5325
5356
 
5326
5357
  result = cdef.impl(check_context)
5327
5358
  results.append((name, result))
5328
-
5329
- if result.status == "fail":
5330
- failed_checks.add(name)
5331
- exit_code = 1
5332
- elif result.status == "warn":
5333
- if not ignore_warnings:
5334
- failed_checks.add(name)
5335
- exit_code = 1
5336
- elif result.status == "skip":
5337
- # Treat explicit skip from impl as non-failure (no cascade)
5338
- pass
5359
+ record(name, result)
5339
5360
 
5340
5361
  return results, exit_code
5341
5362
 
@@ -0,0 +1,79 @@
1
+ """Registration-time validation of arg defaults.
2
+
3
+ str args must reject non-string defaults, and list (variadic) args must
4
+ validate their default as a list with correctly-typed elements.
5
+ """
6
+
7
+ import pytest
8
+
9
+ import strictcli
10
+
11
+
12
+ def test_arg_str_default_type_validated():
13
+ """type=str requires a str default."""
14
+ with pytest.raises(ValueError, match="type=str requires a str default"):
15
+ strictcli.Arg(name="x", help="a value", required=False, default=42)
16
+
17
+
18
+ def test_arg_str_default_rejects_bool():
19
+ """type=str rejects a bool default."""
20
+ with pytest.raises(ValueError, match="type=str requires a str default"):
21
+ strictcli.Arg(name="x", help="a value", required=False, default=True)
22
+
23
+
24
+ def test_arg_list_default_must_be_list():
25
+ """A list arg default must be a list."""
26
+ with pytest.raises(ValueError, match="list arg default must be a list"):
27
+ strictcli.Arg(
28
+ name="items", help="the items", type=list[str],
29
+ variadic=True, required=False, default="nope",
30
+ )
31
+
32
+
33
+ def test_arg_list_default_empty_rejected():
34
+ """An explicit empty list default is redundant and rejected."""
35
+ with pytest.raises(
36
+ ValueError,
37
+ match="explicit empty default is redundant for list args",
38
+ ):
39
+ strictcli.Arg(
40
+ name="items", help="the items", type=list[str],
41
+ variadic=True, required=False, default=[],
42
+ )
43
+
44
+
45
+ def test_arg_list_default_element_type_validated():
46
+ """Element types of a list arg default are validated."""
47
+ with pytest.raises(ValueError, match="default element 1 is not of type str"):
48
+ strictcli.Arg(
49
+ name="items", help="the items", type=list[str],
50
+ variadic=True, required=False, default=["a", 2],
51
+ )
52
+
53
+
54
+ def test_arg_list_default_int_element_type_validated():
55
+ """Element types of an int list arg default are validated."""
56
+ with pytest.raises(ValueError, match="default element 0 is not of type int"):
57
+ strictcli.Arg(
58
+ name="nums", help="the numbers", type=list[int],
59
+ variadic=True, required=False, default=["1"],
60
+ )
61
+
62
+
63
+ def test_arg_list_default_valid_str():
64
+ """A well-typed list default is accepted."""
65
+ a = strictcli.Arg(
66
+ name="items", help="the items", type=list[str],
67
+ variadic=True, required=False, default=["a", "b"],
68
+ )
69
+ assert a.default == ["a", "b"]
70
+
71
+
72
+ def test_arg_list_default_float_coerces_int():
73
+ """Int elements in a float list default are coerced to float."""
74
+ a = strictcli.Arg(
75
+ name="ratios", help="the ratios", type=list[float],
76
+ variadic=True, required=False, default=[1, 2.5],
77
+ )
78
+ assert a.default == [1.0, 2.5]
79
+ assert isinstance(a.default[0], float)
@@ -267,7 +267,10 @@ class TestRunChecks:
267
267
  assert exit_code == 1
268
268
  assert results[0][1].status == "warn"
269
269
 
270
- def test_warn_cascades_skip_when_not_ignored(self, tmp_path, monkeypatch):
270
+ def test_warn_dependency_runs_dependent(self, tmp_path, monkeypatch):
271
+ # A warn satisfies a dependency: only fail (or cascade-skip) skips
272
+ # dependents. The warn still makes the run exit non-zero when
273
+ # ignore_warnings=False, but the dependent must run.
271
274
  defs = {
272
275
  "b": _make_check_def(
273
276
  "b",
@@ -286,7 +289,66 @@ class TestRunChecks:
286
289
  assert exit_code == 1
287
290
  statuses = {name: r.status for name, r in results}
288
291
  assert statuses["b"] == "warn"
289
- assert statuses["a"] == "skip"
292
+ assert statuses["a"] == "pass"
293
+
294
+ def test_warn_dependency_transitive_dependents_run(self, tmp_path, monkeypatch):
295
+ # warn -> dependent -> transitive dependent: the whole chain runs.
296
+ defs = {
297
+ "c": _make_check_def(
298
+ "c",
299
+ impl=lambda ctx: CheckResult(status="warn", message="Warning"),
300
+ ),
301
+ "b": _make_check_def(
302
+ "b",
303
+ depends_on=["c"],
304
+ impl=lambda ctx: CheckResult(status="pass", message="B OK"),
305
+ ),
306
+ "a": _make_check_def(
307
+ "a",
308
+ depends_on=["b"],
309
+ impl=lambda ctx: CheckResult(status="pass", message="A OK"),
310
+ ),
311
+ }
312
+ app = self._make_app_with_checks(defs, tmp_path, monkeypatch)
313
+ ctx = SimpleContext(project_root=tmp_path)
314
+ order = _resolve_check_order(app._check_defs, {"a", "b", "c"})
315
+ results, exit_code = _run_checks(app._check_defs, order, ctx, ignore_warnings=False)
316
+ assert exit_code == 1
317
+ statuses = {name: r.status for name, r in results}
318
+ assert statuses["c"] == "warn"
319
+ assert statuses["b"] == "pass"
320
+ assert statuses["a"] == "pass"
321
+
322
+ def test_warn_from_scope_adapter_runs_dependent(self, tmp_path, monkeypatch):
323
+ # When the scope adapter short-circuits a check with a warn result,
324
+ # the warn must also satisfy dependencies (no cascade-skip).
325
+ defs = {
326
+ "b": _make_check_def(
327
+ "b",
328
+ impl=lambda ctx: CheckResult(status="pass", message="unused"),
329
+ ),
330
+ "a": _make_check_def(
331
+ "a",
332
+ depends_on=["b"],
333
+ impl=lambda ctx: CheckResult(status="pass", message="A OK"),
334
+ ),
335
+ }
336
+ app = self._make_app_with_checks(defs, tmp_path, monkeypatch)
337
+ # Give "b" a scope so the adapter is consulted for it.
338
+ app._check_defs["b"].scope = "some-scope"
339
+
340
+ def adapter(context, scope):
341
+ return CheckResult(status="warn", message="adapter warning")
342
+
343
+ ctx = SimpleContext(project_root=tmp_path)
344
+ order = _resolve_check_order(app._check_defs, {"a", "b"})
345
+ results, exit_code = _run_checks(
346
+ app._check_defs, order, ctx, ignore_warnings=False, scope_adapter=adapter
347
+ )
348
+ assert exit_code == 1
349
+ statuses = {name: r.status for name, r in results}
350
+ assert statuses["b"] == "warn"
351
+ assert statuses["a"] == "pass"
290
352
 
291
353
  def test_warn_does_not_cascade_when_ignored(self, tmp_path, monkeypatch):
292
354
  defs = {
@@ -0,0 +1,163 @@
1
+ """Tests for choices/validate exemption of None values.
2
+
3
+ A None value only arises when a flag or arg was not passed (an unset mutex
4
+ flag, or an arg with default=None); a CLI-supplied value is never None.
5
+ Choices validation and custom validators must skip None instead of failing
6
+ with "invalid value 'None'".
7
+ """
8
+
9
+ import pytest
10
+
11
+ import strictcli
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Mutex flags: the unset member resolves to None
16
+ # ---------------------------------------------------------------------------
17
+
18
+
19
+ def _mutex_choices_app():
20
+ mg = strictcli.MutexGroup(
21
+ flags=[
22
+ strictcli.Flag(
23
+ name="format", type=str, help="output format",
24
+ default=None, choices=["text", "json"],
25
+ ),
26
+ strictcli.Flag(name="output", type=str, help="output path", default=None),
27
+ ],
28
+ )
29
+ app = strictcli.App(name="test", version="1.0.0", help="test app")
30
+
31
+ @app.command("cmd", help="a command", mutex=[mg])
32
+ def cmd(format, output):
33
+ print(f"format={format} output={output}")
34
+
35
+ return app
36
+
37
+
38
+ def test_mutex_flag_choices_unset_not_validated():
39
+ """Choices on an unset mutex flag must not fire when the other member is passed."""
40
+ app = _mutex_choices_app()
41
+ r = app.test(["cmd", "--output", "out.txt"])
42
+ assert r.exit_code == 0
43
+ assert "format=None output=out.txt" in r.stdout
44
+
45
+
46
+ def test_mutex_flag_choices_passed_valid():
47
+ """A valid choice on the mutex flag is still accepted."""
48
+ app = _mutex_choices_app()
49
+ r = app.test(["cmd", "--format", "json"])
50
+ assert r.exit_code == 0
51
+ assert "format=json output=None" in r.stdout
52
+
53
+
54
+ def test_mutex_flag_choices_passed_invalid():
55
+ """An invalid choice on the mutex flag is still rejected."""
56
+ app = _mutex_choices_app()
57
+ r = app.test(["cmd", "--format", "xml"])
58
+ assert r.exit_code == 1
59
+ assert "--format: invalid value 'xml', must be one of: text, json" in r.stderr
60
+
61
+
62
+ # ---------------------------------------------------------------------------
63
+ # Args: default=None (or no default) resolves to None
64
+ # ---------------------------------------------------------------------------
65
+
66
+
67
+ def _arg_choices_app(**arg_kwargs):
68
+ app = strictcli.App(name="test", version="1.0.0", help="test app")
69
+
70
+ @app.command(
71
+ "cmd",
72
+ help="a command",
73
+ args=[strictcli.Arg(
74
+ name="env", help="target env", required=False,
75
+ choices=["dev", "staging", "prod"], **arg_kwargs,
76
+ )],
77
+ )
78
+ def cmd(env=None):
79
+ print(f"env={env}")
80
+
81
+ return app
82
+
83
+
84
+ def test_arg_none_default_choices_not_passed():
85
+ """Optional arg with default=None and choices, not passed -> succeeds."""
86
+ app = _arg_choices_app(default=None)
87
+ r = app.test(["cmd"])
88
+ assert r.exit_code == 0
89
+ assert "env=None" in r.stdout
90
+
91
+
92
+ def test_arg_optional_no_default_choices_not_passed():
93
+ """Optional arg with no default and choices, not passed -> succeeds."""
94
+ app = _arg_choices_app()
95
+ r = app.test(["cmd"])
96
+ assert r.exit_code == 0
97
+ assert "env=None" in r.stdout
98
+
99
+
100
+ def test_arg_none_default_choices_passed_valid():
101
+ """A valid choice on the optional arg is still accepted."""
102
+ app = _arg_choices_app(default=None)
103
+ r = app.test(["cmd", "prod"])
104
+ assert r.exit_code == 0
105
+ assert "env=prod" in r.stdout
106
+
107
+
108
+ def test_arg_none_default_choices_passed_invalid():
109
+ """An invalid choice on the optional arg is still rejected."""
110
+ app = _arg_choices_app(default=None)
111
+ r = app.test(["cmd", "local"])
112
+ assert r.exit_code == 1
113
+ assert (
114
+ "argument 'env': invalid value 'local', must be one of: dev, staging, prod"
115
+ in r.stderr
116
+ )
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Custom validators: not run for None (not-passed) values
121
+ # ---------------------------------------------------------------------------
122
+
123
+
124
+ def _name_validator(val):
125
+ if not isinstance(val, str):
126
+ raise ValueError(f"validator received non-string value {val!r}")
127
+ if val == "bad":
128
+ raise ValueError("bad name")
129
+
130
+
131
+ def _mutex_validate_app():
132
+ mg = strictcli.MutexGroup(
133
+ flags=[
134
+ strictcli.Flag(
135
+ name="name", type=str, help="a name",
136
+ default=None, validate=_name_validator,
137
+ ),
138
+ strictcli.Flag(name="id", type=str, help="an id", default=None),
139
+ ],
140
+ )
141
+ app = strictcli.App(name="test", version="1.0.0", help="test app")
142
+
143
+ @app.command("cmd", help="a command", mutex=[mg])
144
+ def cmd(name, id):
145
+ print(f"name={name} id={id}")
146
+
147
+ return app
148
+
149
+
150
+ def test_mutex_flag_validate_unset_not_called():
151
+ """A custom validator must not run for an unset mutex flag (value None)."""
152
+ app = _mutex_validate_app()
153
+ r = app.test(["cmd", "--id", "42"])
154
+ assert r.exit_code == 0
155
+ assert "name=None id=42" in r.stdout
156
+
157
+
158
+ def test_mutex_flag_validate_passed_still_runs():
159
+ """A passed value is still validated."""
160
+ app = _mutex_validate_app()
161
+ r = app.test(["cmd", "--name", "bad"])
162
+ assert r.exit_code == 1
163
+ assert "--name: bad name" in r.stderr
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.23.0"
235
+ version = "0.24.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
File without changes
File without changes