strictcli 0.14.0__tar.gz → 0.15.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 (137) hide show
  1. strictcli-0.15.0/.rlsbl/changes/.validated +1 -0
  2. strictcli-0.15.0/.rlsbl/changes/0.15.0.jsonl +6 -0
  3. strictcli-0.15.0/.rlsbl/releases/unreleased.toml +0 -0
  4. strictcli-0.15.0/.rlsbl/releases/v0.15.0.toml +8 -0
  5. {strictcli-0.14.0 → strictcli-0.15.0}/CHANGELOG.md +13 -3
  6. {strictcli-0.14.0 → strictcli-0.15.0}/PKG-INFO +1 -1
  7. {strictcli-0.14.0 → strictcli-0.15.0}/package-lock.json +2 -2
  8. {strictcli-0.14.0 → strictcli-0.15.0}/package.json +1 -1
  9. {strictcli-0.14.0 → strictcli-0.15.0}/pyproject.toml +1 -1
  10. {strictcli-0.14.0 → strictcli-0.15.0}/strictcli/__init__.py +63 -27
  11. strictcli-0.15.0/tests/test_config_set_bugs.py +242 -0
  12. {strictcli-0.14.0 → strictcli-0.15.0}/uv.lock +1 -1
  13. strictcli-0.14.0/.rlsbl/changes/.validated +0 -1
  14. {strictcli-0.14.0 → strictcli-0.15.0}/.claude/settings.json +0 -0
  15. {strictcli-0.14.0 → strictcli-0.15.0}/.github/workflows/ci.yml +0 -0
  16. {strictcli-0.14.0 → strictcli-0.15.0}/.github/workflows/publish.yml +0 -0
  17. {strictcli-0.14.0 → strictcli-0.15.0}/.gitignore +0 -0
  18. {strictcli-0.14.0 → strictcli-0.15.0}/.npmignore +0 -0
  19. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.claude/settings.json +0 -0
  20. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  21. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  22. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.gitignore +0 -0
  23. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.npmignore +0 -0
  24. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/hooks/post-release.sh +0 -0
  25. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +0 -0
  26. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +0 -0
  27. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/lint/go.toml +0 -0
  28. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/lint/npm.toml +0 -0
  29. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  30. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/CHANGELOG.md +0 -0
  31. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/CLAUDE.md +0 -0
  32. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/LICENSE +0 -0
  33. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.10.0.jsonl +0 -0
  34. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.10.0.md +0 -0
  35. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.11.0.jsonl +0 -0
  36. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.11.0.md +0 -0
  37. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.12.0.jsonl +0 -0
  38. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.12.0.md +0 -0
  39. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.13.0.jsonl +0 -0
  40. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.13.0.md +0 -0
  41. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.14.0.jsonl +0 -0
  42. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.14.0.md +0 -0
  43. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.4.0.jsonl +0 -0
  44. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.4.0.md +0 -0
  45. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.4.1.jsonl +0 -0
  46. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.4.1.md +0 -0
  47. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.5.0.jsonl +0 -0
  48. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.5.0.md +0 -0
  49. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.6.0.jsonl +0 -0
  50. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.6.0.md +0 -0
  51. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.6.1.jsonl +0 -0
  52. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.6.1.md +0 -0
  53. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.7.0.jsonl +0 -0
  54. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.7.0.md +0 -0
  55. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.7.1.jsonl +0 -0
  56. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.7.1.md +0 -0
  57. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.0.jsonl +0 -0
  58. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.0.md +0 -0
  59. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.1.jsonl +0 -0
  60. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.1.md +0 -0
  61. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.2.jsonl +0 -0
  62. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.2.md +0 -0
  63. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.3.jsonl +0 -0
  64. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.3.md +0 -0
  65. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.4.jsonl +0 -0
  66. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.4.md +0 -0
  67. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.5.jsonl +0 -0
  68. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.5.md +0 -0
  69. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.6.jsonl +0 -0
  70. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.6.md +0 -0
  71. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.7.jsonl +0 -0
  72. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.7.md +0 -0
  73. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.9.0.jsonl +0 -0
  74. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.9.0.md +0 -0
  75. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.9.1.jsonl +0 -0
  76. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.9.1.md +0 -0
  77. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/unreleased.jsonl +0 -0
  78. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/config.json +0 -0
  79. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/hashes.json +0 -0
  80. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/hooks/post-release.sh +0 -0
  81. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/hooks/pre-checks.sh +0 -0
  82. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/hooks/pre-release.sh +0 -0
  83. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/lint/go.toml +0 -0
  84. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/lint/npm.toml +0 -0
  85. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/lint/python.toml +0 -0
  86. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.10.0.toml +0 -0
  87. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.11.0.toml +0 -0
  88. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.12.0.toml +0 -0
  89. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.13.0.toml +0 -0
  90. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.8.5.toml +0 -0
  91. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.8.6.toml +0 -0
  92. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.8.7.toml +0 -0
  93. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.9.0.toml +0 -0
  94. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.9.1.toml +0 -0
  95. {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/version +0 -0
  96. {strictcli-0.14.0 → strictcli-0.15.0}/.strictcli/schema.json +0 -0
  97. {strictcli-0.14.0 → strictcli-0.15.0}/CLAUDE.md +0 -0
  98. {strictcli-0.14.0 → strictcli-0.15.0}/LICENSE +0 -0
  99. {strictcli-0.14.0 → strictcli-0.15.0}/README.md +0 -0
  100. {strictcli-0.14.0 → strictcli-0.15.0}/index.js +0 -0
  101. {strictcli-0.14.0 → strictcli-0.15.0}/postinstall.js +0 -0
  102. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_arg_default.py +0 -0
  103. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_at_prefix.py +0 -0
  104. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_auto_version.py +0 -0
  105. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_check_command.py +0 -0
  106. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_check_discovery.py +0 -0
  107. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_check_runner.py +0 -0
  108. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_check_schema.py +0 -0
  109. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_check_types.py +0 -0
  110. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_choices.py +0 -0
  111. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_command_help_suggestion.py +0 -0
  112. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_config.py +0 -0
  113. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_config_file_path.py +0 -0
  114. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_deep_nesting.py +0 -0
  115. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_dependencies.py +0 -0
  116. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_deprecated.py +0 -0
  117. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_dump_schema.py +0 -0
  118. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_e2e.py +0 -0
  119. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_env.py +0 -0
  120. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_exit_codes.py +0 -0
  121. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_float_type.py +0 -0
  122. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_global_flags.py +0 -0
  123. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_help.py +0 -0
  124. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_int_type.py +0 -0
  125. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_mutex.py +0 -0
  126. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_nesting.py +0 -0
  127. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_parser.py +0 -0
  128. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_passthrough.py +0 -0
  129. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_registration.py +0 -0
  130. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_repeatable.py +0 -0
  131. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_tagdsl.py +0 -0
  132. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_tags.py +0 -0
  133. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_toml_loading.py +0 -0
  134. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_validate.py +0 -0
  135. {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_variadic.py +0 -0
  136. {strictcli-0.14.0 → strictcli-0.15.0}/todo/.defer/deferred.md +0 -0
  137. {strictcli-0.14.0 → strictcli-0.15.0}/todo/.done/original-idea.md +0 -0
@@ -0,0 +1 @@
1
+ a6dff298b7a2dad7fa928b088eeb4f3f9c0b2cf5
@@ -0,0 +1,6 @@
1
+ {"commits":["057edc5200703fe2a2d37d28f488833b35254a06"],"user_facing":false}
2
+ {"commits":["e6af4e228227d55585c09ec9012cca846eff2651"],"user_facing":false}
3
+ {"commits":["e5dc999d0d920ba0737d2c565be818af8465de1c"],"user_facing":true,"description":"**Breaking.** `config set` now validates keys against registered flags and rejects unknown keys.","type":"breaking"}
4
+ {"commits":["e5dc999d0d920ba0737d2c565be818af8465de1c"],"user_facing":true,"description":"**New feature.** `config set` coerces values to the flag's declared type (int, bool, float) before writing to config.","type":"feature"}
5
+ {"commits":["4cf2df20f76491a8d9b504307de8d78ca514e7ab"],"user_facing":false}
6
+ {"commits":["050a53f9af245a308f6d5895307beb7f479c9c27"],"user_facing":false}
File without changes
@@ -0,0 +1,8 @@
1
+ # Version bump type: patch, minor, or major
2
+ bump = "minor"
3
+ # Short description of this release (required)
4
+ description = "Config set type coercion and key validation"
5
+ # Optional context explaining why these changes were made
6
+ context = "config set now validates keys against registered flags and coerces string values to the flag's declared type before writing. This is a breaking change: unknown keys are rejected."
7
+ include = ["pypi", "npm"]
8
+ exclude = []
@@ -2,17 +2,27 @@
2
2
 
3
3
  # Changelog
4
4
 
5
- ## 0.14.0
5
+ ## 0.15.0
6
6
 
7
- checks_embed for inline TOML data
7
+ Config set type coercion and key validation
8
8
 
9
9
  <details>
10
10
  <summary>Context</summary>
11
11
 
12
- Adds checks_embed parameter as an alternative to checks_path, allowing TOML bytes to be passed directly without requiring a file on disk.
12
+ config set now validates keys against registered flags and coerces string values to the flag's declared type before writing. This is a breaking change: unknown keys are rejected.
13
13
 
14
14
  </details>
15
15
 
16
+ ### Breaking
17
+
18
+ - **Breaking.** `config set` now validates keys against registered flags and rejects unknown keys.
19
+
20
+ ### Features
21
+
22
+ - **New feature.** `config set` coerces values to the flag's declared type (int, bool, float) before writing to config.
23
+
24
+ ## 0.14.0
25
+
16
26
  ### Features
17
27
 
18
28
  - **New feature.** `checks_embed` parameter accepts raw TOML bytes, enabling inline checks configuration without a file on disk.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.14.0
3
+ Version: 0.15.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.14.0",
3
+ "version": "0.15.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "strictcli",
9
- "version": "0.14.0",
9
+ "version": "0.15.0",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.14.0",
3
+ "version": "0.15.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.14.0"
7
+ version = "0.15.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.14.0"
5
+ __version__ = "0.15.0"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "Tag", "MutexGroup", "CoRequired", "Requires",
@@ -285,6 +285,21 @@ class _ParseError(Exception):
285
285
  self.command_prefix = command_prefix
286
286
 
287
287
 
288
+ def _strict_bool(s: str) -> bool:
289
+ """Parse a boolean string strictly.
290
+
291
+ Accepts: 1, true, yes (case-insensitive) -> True
292
+ Accepts: 0, false, no (case-insensitive) -> False
293
+ Everything else raises ValueError.
294
+ """
295
+ lower = s.lower()
296
+ if lower in ("1", "true", "yes"):
297
+ return True
298
+ if lower in ("0", "false", "no"):
299
+ return False
300
+ raise ValueError(f"expected boolean, got '{s}'")
301
+
302
+
288
303
  def _strict_int(s: str) -> int:
289
304
  """Parse an integer string strictly -- no leading/trailing whitespace allowed.
290
305
 
@@ -1014,7 +1029,7 @@ class App:
1014
1029
  )
1015
1030
 
1016
1031
  # config set
1017
- def _config_set_handler(key, value, **_kw) -> None:
1032
+ def _config_set_handler(key, value, **_kw) -> int:
1018
1033
  path = _config_path(
1019
1034
  app_ref.name,
1020
1035
  override=app_ref.config_path,
@@ -1023,26 +1038,53 @@ class App:
1023
1038
  dir_path = os.path.dirname(path)
1024
1039
  os.makedirs(dir_path, exist_ok=True)
1025
1040
  # Read existing config
1026
- existing: dict = {}
1027
- if os.path.isfile(path):
1028
- if app_ref.config_format == "toml":
1041
+ existing = _load_config(
1042
+ app_ref.name,
1043
+ config_path_override=app_ref.config_path,
1044
+ config_format=app_ref.config_format,
1045
+ )
1046
+
1047
+ # Look up the key against registered flags
1048
+ all_flags = app_ref._collect_all_flags()
1049
+ matched_flag = None
1050
+ for f in all_flags:
1051
+ if _flag_param_name(f.name) == key:
1052
+ matched_flag = f
1053
+ break
1054
+ if matched_flag is None:
1055
+ print(f"config set: unknown key '{key}'", file=sys.stderr)
1056
+ return 1
1057
+
1058
+ # Coerce the string value to the flag's type
1059
+ try:
1060
+ if matched_flag.type == bool:
1061
+ typed_value = _strict_bool(value)
1062
+ elif matched_flag.type == int:
1029
1063
  try:
1030
- with open(path, "rb") as fh:
1031
- existing = tomllib.load(fh)
1032
- except (tomllib.TOMLDecodeError, UnicodeDecodeError):
1033
- existing = {}
1034
- else:
1064
+ typed_value = _strict_int(value)
1065
+ except ValueError:
1066
+ raise ValueError(f"expected integer, got '{value}'")
1067
+ elif matched_flag.type == float:
1035
1068
  try:
1036
- with open(path) as fh:
1037
- existing = json.loads(fh.read())
1038
- except (json.JSONDecodeError, ValueError):
1039
- existing = {}
1040
- existing[key] = value
1069
+ typed_value = _strict_float(value)
1070
+ except ValueError as fe:
1071
+ msg = str(fe)
1072
+ if msg in ("NaN is not allowed", "Inf is not allowed"):
1073
+ raise
1074
+ raise ValueError(f"expected float, got '{value}'") from fe
1075
+ else: # str
1076
+ typed_value = value
1077
+ except ValueError as e:
1078
+ print(f"config set: key '{key}': {e}", file=sys.stderr)
1079
+ return 1
1080
+
1081
+ existing[key] = typed_value
1041
1082
  if app_ref.config_format == "toml":
1042
1083
  _write_toml_flat(existing, path)
1043
1084
  else:
1044
1085
  with open(path, "w") as fh:
1045
1086
  fh.write(json.dumps(existing, indent=2) + "\n")
1087
+ return 0
1046
1088
 
1047
1089
  config_grp.commands["set"] = Command(
1048
1090
  name="set",
@@ -1362,12 +1404,9 @@ class App:
1362
1404
  env_val = os.environ.get(f.env)
1363
1405
  if env_val is not None:
1364
1406
  if f.type is bool:
1365
- lower = env_val.lower()
1366
- if lower in ("1", "true", "yes"):
1367
- cli_set[f.name] = True
1368
- elif lower in ("0", "false", "no"):
1369
- cli_set[f.name] = False
1370
- else:
1407
+ try:
1408
+ cli_set[f.name] = _strict_bool(env_val)
1409
+ except ValueError:
1371
1410
  raise _ParseError(
1372
1411
  f"invalid boolean value {env_val!r} for env var "
1373
1412
  f"'{f.env}' (flag '--{f.name}')"
@@ -1760,12 +1799,9 @@ def _parse_command(
1760
1799
  env_val = os.environ.get(f.env)
1761
1800
  if env_val is not None:
1762
1801
  if f.type is bool:
1763
- lower = env_val.lower()
1764
- if lower in ("1", "true", "yes"):
1765
- cli_set[f.name] = True
1766
- elif lower in ("0", "false", "no"):
1767
- cli_set[f.name] = False
1768
- else:
1802
+ try:
1803
+ cli_set[f.name] = _strict_bool(env_val)
1804
+ except ValueError:
1769
1805
  raise _ParseError(
1770
1806
  f"invalid boolean value {env_val!r} for env var "
1771
1807
  f"'{f.env}' (flag '--{f.name}')"
@@ -0,0 +1,242 @@
1
+ """Red tests for config set bugs.
2
+
3
+ These tests document bugs in the config set command and are expected to
4
+ FAIL with the current code. They will turn green when the bugs are fixed.
5
+
6
+ Bug 0.4: config set stores raw strings instead of typed values.
7
+ Bug 0.6: config set accepts unknown keys without validation.
8
+ """
9
+
10
+ import json
11
+
12
+ import strictcli
13
+
14
+
15
+ def test_config_set_writes_typed_values(tmp_path):
16
+ """config set should write typed values (int, bool, float), not strings.
17
+
18
+ Bug: _config_set_handler stores the raw string from argv without
19
+ coercing it to the flag's declared type. So 'config set count 42'
20
+ writes {"count": "42"} instead of {"count": 42}.
21
+ """
22
+ config_file = tmp_path / "config.json"
23
+
24
+ app = strictcli.App(
25
+ name="testapp",
26
+ version="1.0.0",
27
+ help="test app",
28
+ config=True,
29
+ config_path=str(config_file),
30
+ )
31
+
32
+ @app.command("run", help="run something")
33
+ @strictcli.flag("count", type=int, help="how many", default=0)
34
+ @strictcli.flag("verbose", type=bool, help="be verbose", default=False)
35
+ @strictcli.flag("rate", type=float, help="the rate", default=0.0)
36
+ def run(count, verbose, rate):
37
+ pass
38
+
39
+ r = app.test(["config", "set", "count", "42"])
40
+ assert r.exit_code == 0
41
+
42
+ r = app.test(["config", "set", "verbose", "true"])
43
+ assert r.exit_code == 0
44
+
45
+ r = app.test(["config", "set", "rate", "3.14"])
46
+ assert r.exit_code == 0
47
+
48
+ data = json.loads(config_file.read_text())
49
+ assert isinstance(data["count"], int), f"expected int, got {type(data['count']).__name__}: {data['count']!r}"
50
+ assert data["count"] == 42
51
+ assert isinstance(data["verbose"], bool), f"expected bool, got {type(data['verbose']).__name__}: {data['verbose']!r}"
52
+ assert data["verbose"] is True
53
+ assert isinstance(data["rate"], float), f"expected float, got {type(data['rate']).__name__}: {data['rate']!r}"
54
+ assert data["rate"] == 3.14
55
+
56
+
57
+ def test_config_set_rejects_unknown_key(tmp_path):
58
+ """config set should reject keys that don't correspond to any registered flag.
59
+
60
+ Bug: _config_set_handler accepts any key without checking if it
61
+ matches a registered flag. 'config set nonexistent value' silently
62
+ writes {"nonexistent": "value"} to the config file.
63
+ """
64
+ config_file = tmp_path / "config.json"
65
+
66
+ app = strictcli.App(
67
+ name="testapp",
68
+ version="1.0.0",
69
+ help="test app",
70
+ config=True,
71
+ config_path=str(config_file),
72
+ )
73
+
74
+ @app.command("run", help="run something")
75
+ @strictcli.flag("name", type=str, help="the name", default="")
76
+ def run(name):
77
+ pass
78
+
79
+ r = app.test(["config", "set", "nonexistent", "value"])
80
+ assert r.exit_code != 0, f"expected nonzero exit code, got {r.exit_code}"
81
+ assert "nonexistent" in r.stderr, f"expected error about unknown key in stderr, got: {r.stderr!r}"
82
+
83
+
84
+ def _make_app(tmp_path, **extra_flags):
85
+ """Helper: create an app with common typed flags for config set tests."""
86
+ config_file = tmp_path / "config.json"
87
+ app = strictcli.App(
88
+ name="testapp",
89
+ version="1.0.0",
90
+ help="test app",
91
+ config=True,
92
+ config_path=str(config_file),
93
+ )
94
+
95
+ @app.command("run", help="run something")
96
+ @strictcli.flag("debug", type=bool, help="debug mode", default=False)
97
+ @strictcli.flag("count", type=int, help="how many", default=0)
98
+ @strictcli.flag("rate", type=float, help="the rate", default=0.0)
99
+ @strictcli.flag("name", type=str, help="the name", default="")
100
+ def run(debug, count, rate, name):
101
+ pass
102
+
103
+ return app, config_file
104
+
105
+
106
+ def test_config_set_typed_bool(tmp_path):
107
+ """config set coerces boolean values and stores them as JSON booleans."""
108
+ app, config_file = _make_app(tmp_path)
109
+
110
+ for true_val in ("true", "yes", "1", "True", "YES"):
111
+ r = app.test(["config", "set", "debug", true_val])
112
+ assert r.exit_code == 0, f"exit_code={r.exit_code} for '{true_val}': {r.stderr}"
113
+ data = json.loads(config_file.read_text())
114
+ assert data["debug"] is True, f"expected True for '{true_val}', got {data['debug']!r}"
115
+
116
+ for false_val in ("false", "no", "0", "False", "NO"):
117
+ r = app.test(["config", "set", "debug", false_val])
118
+ assert r.exit_code == 0, f"exit_code={r.exit_code} for '{false_val}': {r.stderr}"
119
+ data = json.loads(config_file.read_text())
120
+ assert data["debug"] is False, f"expected False for '{false_val}', got {data['debug']!r}"
121
+
122
+
123
+ def test_config_set_typed_int(tmp_path):
124
+ """config set coerces integer values and stores them as JSON integers."""
125
+ app, config_file = _make_app(tmp_path)
126
+
127
+ r = app.test(["config", "set", "count", "42"])
128
+ assert r.exit_code == 0
129
+ data = json.loads(config_file.read_text())
130
+ assert isinstance(data["count"], int)
131
+ assert data["count"] == 42
132
+
133
+ r = app.test(["config", "set", "count", "0"])
134
+ assert r.exit_code == 0
135
+ data = json.loads(config_file.read_text())
136
+ assert isinstance(data["count"], int)
137
+ assert data["count"] == 0
138
+
139
+
140
+ def test_config_set_typed_float(tmp_path):
141
+ """config set coerces float values and stores them as JSON floats."""
142
+ app, config_file = _make_app(tmp_path)
143
+
144
+ r = app.test(["config", "set", "rate", "3.14"])
145
+ assert r.exit_code == 0
146
+ data = json.loads(config_file.read_text())
147
+ assert isinstance(data["rate"], float)
148
+ assert data["rate"] == 3.14
149
+
150
+ # Integer-like value stored as float when flag type is float
151
+ r = app.test(["config", "set", "rate", "3"])
152
+ assert r.exit_code == 0
153
+ data = json.loads(config_file.read_text())
154
+ assert isinstance(data["rate"], float)
155
+ assert data["rate"] == 3.0
156
+
157
+
158
+ def test_config_set_bad_value(tmp_path):
159
+ """config set rejects values that don't parse as the flag's type."""
160
+ app, _ = _make_app(tmp_path)
161
+
162
+ # Bad int
163
+ r = app.test(["config", "set", "count", "abc"])
164
+ assert r.exit_code != 0
165
+ assert "expected integer, got 'abc'" in r.stderr
166
+
167
+ # Bad bool
168
+ r = app.test(["config", "set", "debug", "maybe"])
169
+ assert r.exit_code != 0
170
+ assert "expected boolean, got 'maybe'" in r.stderr
171
+
172
+ # Bad float
173
+ r = app.test(["config", "set", "rate", "xyz"])
174
+ assert r.exit_code != 0
175
+ assert "expected float, got 'xyz'" in r.stderr
176
+
177
+ # NaN rejected for float
178
+ r = app.test(["config", "set", "rate", "nan"])
179
+ assert r.exit_code != 0
180
+ assert "NaN is not allowed" in r.stderr
181
+
182
+ # Inf rejected for float
183
+ r = app.test(["config", "set", "rate", "inf"])
184
+ assert r.exit_code != 0
185
+ assert "Inf is not allowed" in r.stderr
186
+
187
+
188
+ def test_config_set_unknown_key_error(tmp_path):
189
+ """config set error format for unknown keys matches Go exactly."""
190
+ app, _ = _make_app(tmp_path)
191
+
192
+ r = app.test(["config", "set", "xyz", "value"])
193
+ assert r.exit_code == 1
194
+ assert r.stderr.strip() == "config set: unknown key 'xyz'"
195
+
196
+ r = app.test(["config", "set", "nonexistent_flag", "42"])
197
+ assert r.exit_code == 1
198
+ assert r.stderr.strip() == "config set: unknown key 'nonexistent_flag'"
199
+
200
+
201
+ def test_config_set_round_trip_typed(tmp_path):
202
+ """Values set via config set are read back with correct types by a new app."""
203
+ app, config_file = _make_app(tmp_path)
204
+
205
+ app.test(["config", "set", "debug", "true"])
206
+ app.test(["config", "set", "count", "42"])
207
+ app.test(["config", "set", "rate", "3.14"])
208
+ app.test(["config", "set", "name", "hello"])
209
+
210
+ # Create a fresh app pointing at the same config file
211
+ app2 = strictcli.App(
212
+ name="testapp",
213
+ version="1.0.0",
214
+ help="test app",
215
+ config=True,
216
+ config_path=str(config_file),
217
+ )
218
+
219
+ received = {}
220
+
221
+ @app2.command("run", help="run something")
222
+ @strictcli.flag("debug", type=bool, help="debug mode", default=False)
223
+ @strictcli.flag("count", type=int, help="how many", default=0)
224
+ @strictcli.flag("rate", type=float, help="the rate", default=0.0)
225
+ @strictcli.flag("name", type=str, help="the name", default="")
226
+ def run(debug, count, rate, name):
227
+ received["debug"] = debug
228
+ received["count"] = count
229
+ received["rate"] = rate
230
+ received["name"] = name
231
+
232
+ r = app2.test(["run"])
233
+ assert r.exit_code == 0
234
+
235
+ assert received["debug"] is True
236
+ assert isinstance(received["debug"], bool)
237
+ assert received["count"] == 42
238
+ assert isinstance(received["count"], int)
239
+ assert received["rate"] == 3.14
240
+ assert isinstance(received["rate"], float)
241
+ assert received["name"] == "hello"
242
+ assert isinstance(received["name"], str)
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.14.0"
235
+ version = "0.15.0"
236
236
  source = { editable = "." }
237
237
 
238
238
  [package.dev-dependencies]
@@ -1 +0,0 @@
1
- caba81b23948f3b79c908ccd06ed76596881c075
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