strictcli 0.19.0__tar.gz → 0.20.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 (161) hide show
  1. strictcli-0.20.0/.rlsbl/changes/.validated +1 -0
  2. strictcli-0.20.0/.rlsbl/changes/0.20.0.jsonl +4 -0
  3. strictcli-0.20.0/.rlsbl/releases/v0.20.0.toml +8 -0
  4. {strictcli-0.19.0 → strictcli-0.20.0}/CHANGELOG.md +54 -3
  5. {strictcli-0.19.0 → strictcli-0.20.0}/PKG-INFO +1 -1
  6. {strictcli-0.19.0 → strictcli-0.20.0}/package-lock.json +2 -2
  7. {strictcli-0.19.0 → strictcli-0.20.0}/package.json +1 -1
  8. {strictcli-0.19.0 → strictcli-0.20.0}/pyproject.toml +1 -1
  9. {strictcli-0.19.0 → strictcli-0.20.0}/strictcli/__init__.py +118 -24
  10. strictcli-0.20.0/tests/test_command_tags.py +419 -0
  11. {strictcli-0.19.0 → strictcli-0.20.0}/uv.lock +1 -1
  12. strictcli-0.19.0/.rlsbl/changes/.validated +0 -1
  13. {strictcli-0.19.0 → strictcli-0.20.0}/.claude/settings.json +0 -0
  14. {strictcli-0.19.0 → strictcli-0.20.0}/.github/workflows/ci.yml +0 -0
  15. {strictcli-0.19.0 → strictcli-0.20.0}/.github/workflows/publish.yml +0 -0
  16. {strictcli-0.19.0 → strictcli-0.20.0}/.gitignore +0 -0
  17. {strictcli-0.19.0 → strictcli-0.20.0}/.npmignore +0 -0
  18. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/.claude/settings.json +0 -0
  19. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  20. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  21. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/.gitignore +0 -0
  22. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/.npmignore +0 -0
  23. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/.rlsbl/hooks/post-release.sh +0 -0
  24. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +0 -0
  25. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +0 -0
  26. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/.rlsbl/lint/npm.toml +0 -0
  27. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  28. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/CHANGELOG.md +0 -0
  29. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/CLAUDE.md +0 -0
  30. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/bases/LICENSE +0 -0
  31. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.10.0.jsonl +0 -0
  32. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.10.0.md +0 -0
  33. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.11.0.jsonl +0 -0
  34. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.11.0.md +0 -0
  35. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.12.0.jsonl +0 -0
  36. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.12.0.md +0 -0
  37. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.13.0.jsonl +0 -0
  38. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.13.0.md +0 -0
  39. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.14.0.jsonl +0 -0
  40. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.14.0.md +0 -0
  41. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.15.0.jsonl +0 -0
  42. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.15.0.md +0 -0
  43. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.15.1.jsonl +0 -0
  44. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.15.1.md +0 -0
  45. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.16.0.jsonl +0 -0
  46. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.16.0.md +0 -0
  47. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.16.1.jsonl +0 -0
  48. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.16.1.md +0 -0
  49. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.17.0.jsonl +0 -0
  50. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.17.0.md +0 -0
  51. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.18.0.jsonl +0 -0
  52. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.18.0.md +0 -0
  53. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.19.0.jsonl +0 -0
  54. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.19.0.md +0 -0
  55. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.4.0.jsonl +0 -0
  56. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.4.0.md +0 -0
  57. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.4.1.jsonl +0 -0
  58. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.4.1.md +0 -0
  59. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.5.0.jsonl +0 -0
  60. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.5.0.md +0 -0
  61. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.6.0.jsonl +0 -0
  62. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.6.0.md +0 -0
  63. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.6.1.jsonl +0 -0
  64. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.6.1.md +0 -0
  65. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.7.0.jsonl +0 -0
  66. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.7.0.md +0 -0
  67. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.7.1.jsonl +0 -0
  68. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.7.1.md +0 -0
  69. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.0.jsonl +0 -0
  70. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.0.md +0 -0
  71. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.1.jsonl +0 -0
  72. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.1.md +0 -0
  73. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.2.jsonl +0 -0
  74. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.2.md +0 -0
  75. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.3.jsonl +0 -0
  76. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.3.md +0 -0
  77. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.4.jsonl +0 -0
  78. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.4.md +0 -0
  79. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.5.jsonl +0 -0
  80. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.5.md +0 -0
  81. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.6.jsonl +0 -0
  82. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.6.md +0 -0
  83. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.7.jsonl +0 -0
  84. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.8.7.md +0 -0
  85. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.9.0.jsonl +0 -0
  86. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.9.0.md +0 -0
  87. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.9.1.jsonl +0 -0
  88. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/0.9.1.md +0 -0
  89. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/changes/unreleased.jsonl +0 -0
  90. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/config.json +0 -0
  91. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/hashes.json +0 -0
  92. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/hooks/post-release.sh +0 -0
  93. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/hooks/pre-checks.sh +0 -0
  94. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/hooks/pre-release.sh +0 -0
  95. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/lint/npm.toml +0 -0
  96. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/lint/python.toml +0 -0
  97. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/unreleased.toml +0 -0
  98. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.10.0.toml +0 -0
  99. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.11.0.toml +0 -0
  100. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.12.0.toml +0 -0
  101. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.13.0.toml +0 -0
  102. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.15.0.toml +0 -0
  103. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.15.1.toml +0 -0
  104. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.16.0.toml +0 -0
  105. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.16.1.toml +0 -0
  106. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.17.0.toml +0 -0
  107. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.18.0.toml +0 -0
  108. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.19.0.toml +0 -0
  109. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.8.5.toml +0 -0
  110. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.8.6.toml +0 -0
  111. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.8.7.toml +0 -0
  112. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.9.0.toml +0 -0
  113. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/releases/v0.9.1.toml +0 -0
  114. {strictcli-0.19.0 → strictcli-0.20.0}/.rlsbl/version +0 -0
  115. {strictcli-0.19.0 → strictcli-0.20.0}/.strictcli/schema.json +0 -0
  116. {strictcli-0.19.0 → strictcli-0.20.0}/CLAUDE.md +0 -0
  117. {strictcli-0.19.0 → strictcli-0.20.0}/LICENSE +0 -0
  118. {strictcli-0.19.0 → strictcli-0.20.0}/README.md +0 -0
  119. {strictcli-0.19.0 → strictcli-0.20.0}/index.js +0 -0
  120. {strictcli-0.19.0 → strictcli-0.20.0}/postinstall.js +0 -0
  121. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_arg_default.py +0 -0
  122. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_at_prefix.py +0 -0
  123. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_auto_version.py +0 -0
  124. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_check_command.py +0 -0
  125. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_check_discovery.py +0 -0
  126. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_check_public_api.py +0 -0
  127. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_check_runner.py +0 -0
  128. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_check_schema.py +0 -0
  129. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_check_types.py +0 -0
  130. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_choices.py +0 -0
  131. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_command_help_suggestion.py +0 -0
  132. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_config.py +0 -0
  133. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_config_file_path.py +0 -0
  134. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_config_set_bugs.py +0 -0
  135. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_deep_nesting.py +0 -0
  136. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_dependencies.py +0 -0
  137. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_deprecated.py +0 -0
  138. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_dump_schema.py +0 -0
  139. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_e2e.py +0 -0
  140. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_env.py +0 -0
  141. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_exit_codes.py +0 -0
  142. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_flag_sets.py +0 -0
  143. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_float_type.py +0 -0
  144. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_global_flags.py +0 -0
  145. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_help.py +0 -0
  146. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_int_type.py +0 -0
  147. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_mutex.py +0 -0
  148. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_nesting.py +0 -0
  149. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_parser.py +0 -0
  150. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_passthrough.py +0 -0
  151. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_registration.py +0 -0
  152. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_repeatable.py +0 -0
  153. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_tagdsl.py +0 -0
  154. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_toml_loading.py +0 -0
  155. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_unique.py +0 -0
  156. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_utilities.py +0 -0
  157. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_validate.py +0 -0
  158. {strictcli-0.19.0 → strictcli-0.20.0}/tests/test_variadic.py +0 -0
  159. {strictcli-0.19.0 → strictcli-0.20.0}/todo/.defer/deferred.md +0 -0
  160. {strictcli-0.19.0 → strictcli-0.20.0}/todo/.done/original-idea.md +0 -0
  161. {strictcli-0.19.0 → strictcli-0.20.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -0,0 +1 @@
1
+ 019cae063cca2c44324d0c76260c72e72334b7c8
@@ -0,0 +1,4 @@
1
+ {"commits":["9e7e116531923a9db84c2f9e7fc6326a9bf01079","a68465be720ee0728e72a09f5ba6bd3d563a9600"],"user_facing":true,"description":"**Breaking.** Command dataclass is now `frozen=True` with `tuple` fields instead of `list`.","type":"breaking"}
2
+ {"commits":["c5009427ea363e04d8c0096a1ec0628547317866","7808c1c5b08272af346aa4d0e9d69ff3ccef8c78"],"user_facing":true,"description":"**New.** Command tags: string labels on commands and groups with group-to-command inheritance.","type":"feature"}
3
+ {"commits":["ef7a4711dbcf7c0a53a3666de4507aab7c1cf9e0"],"user_facing":true,"description":"**New.** Tag contracts: registration-time validation that tagged commands have required flags.","type":"feature"}
4
+ {"commits":["bc911b9134605042b454bc2192c3fe68ee6f92bf","ff5ff5aa65b216fec2788f942751f11a8ca8b243"],"user_facing":false}
@@ -0,0 +1,8 @@
1
+ # Version bump type: patch, minor, or major
2
+ bump = "minor"
3
+ # Short description of this release (required)
4
+ description = "Frozen Command, command tags, tag contracts"
5
+ # Optional context explaining why these changes were made
6
+ context = "Breaking: Command dataclass is now frozen=True with tuple fields (was mutable list). New: command tags (string labels with group inheritance) and tag contracts (registration-time validation that tagged commands have required flags)."
7
+ include = ["pypi", "npm"]
8
+ exclude = []
@@ -2,29 +2,51 @@
2
2
 
3
3
  # Changelog
4
4
 
5
- ## 0.19.0
5
+ ## 0.20.0
6
6
 
7
- Renamed Tag to FlagSet
7
+ Frozen Command, command tags, tag contracts
8
8
 
9
9
  <details>
10
10
  <summary>Context</summary>
11
11
 
12
- Breaking: Tag class renamed to FlagSet, tags= parameter renamed to flag_sets= across all API surfaces. The Tag name was misleading it implied labeling/annotation, but the type is actually a reusable bundle of flags.
12
+ Breaking: Command dataclass is now frozen=True with tuple fields (was mutable list). New: command tags (string labels with group inheritance) and tag contracts (registration-time validation that tagged commands have required flags).
13
13
 
14
14
  </details>
15
15
 
16
16
  ### Breaking
17
17
 
18
+ - **Breaking.** Command dataclass is now `frozen=True` with `tuple` fields instead of `list`.
19
+
20
+ ### Features
21
+
22
+ - **New.** Command tags: string labels on commands and groups with group-to-command inheritance.
23
+ - **New.** Tag contracts: registration-time validation that tagged commands have required flags.
24
+
25
+ ## 0.19.0
26
+
27
+ ### Breaking
28
+
18
29
  - **Breaking.** Renamed `Tag` to `FlagSet` and `tags=` parameter to `flag_sets=` across all API surfaces.
19
30
 
20
31
  ## 0.18.0
21
32
 
33
+ Public API for running checks programmatically
34
+
35
+ <details>
36
+ <summary>Context</summary>
37
+
38
+ Adds App.run_checks(), format_check_results(), format_check_results_json(), and CheckRunResult — a stable public API replacing the private _filter_checks/_resolve_check_order/_run_checks/_check_format_human functions that consumers like rlsbl were forced to use.
39
+
40
+ </details>
41
+
22
42
  ### Features
23
43
 
24
44
  - **New.** Public API for running checks programmatically: `App.run_checks()`, `format_check_results()`, `format_check_results_json()`, `CheckRunResult`.
25
45
 
26
46
  ## 0.17.0
27
47
 
48
+ Repeatable flag default validation and help text display
49
+
28
50
  ### Breaking
29
51
 
30
52
  - **Breaking.** Repeatable flag defaults are now validated at registration: must be a list, `default=[]` is rejected as redundant, and element types must match the flag type.
@@ -35,6 +57,8 @@ Breaking: Tag class renamed to FlagSet, tags= parameter renamed to flag_sets= ac
35
57
 
36
58
  ## 0.16.1
37
59
 
60
+ Error message fixes and help text improvements
61
+
38
62
  ### Fixes
39
63
 
40
64
  - **Fix.** `config set` mutex error messages now include the `config set:` prefix for consistency.
@@ -42,6 +66,15 @@ Breaking: Tag class renamed to FlagSet, tags= parameter renamed to flag_sets= ac
42
66
 
43
67
  ## 0.16.0
44
68
 
69
+ unique and env_separator flag fields, config array coercion, config set repeatable support
70
+
71
+ <details>
72
+ <summary>Context</summary>
73
+
74
+ Breaking: config show now requires --plain or --json. New features: unique field enforces no duplicate values on repeatable flags, env_separator controls how env vars are split into arrays, config arrays are coerced to declared types, config set supports repeatable flags with --clear and --default.
75
+
76
+ </details>
77
+
45
78
  ### Breaking
46
79
 
47
80
  - **Config show.** `config show` now requires explicit `--plain` or `--json` flag instead of defaulting to plain text.
@@ -61,6 +94,15 @@ Breaking: Tag class renamed to FlagSet, tags= parameter renamed to flag_sets= ac
61
94
 
62
95
  ## 0.15.1
63
96
 
97
+ Config show bool parity and negative values in config set
98
+
99
+ <details>
100
+ <summary>Context</summary>
101
+
102
+ config show now outputs lowercase true/false matching Go. config set accepts negative numeric values.
103
+
104
+ </details>
105
+
64
106
  ### Breaking
65
107
 
66
108
  - **Breaking.** Config set now accepts negative numeric values. Unknown single-dash tokens produce 'unexpected argument' instead of 'unknown flag'.
@@ -71,6 +113,15 @@ Breaking: Tag class renamed to FlagSet, tags= parameter renamed to flag_sets= ac
71
113
 
72
114
  ## 0.15.0
73
115
 
116
+ Config set type coercion and key validation
117
+
118
+ <details>
119
+ <summary>Context</summary>
120
+
121
+ 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.
122
+
123
+ </details>
124
+
74
125
  ### Breaking
75
126
 
76
127
  - **Breaking.** `config set` now validates keys against registered flags and rejects unknown keys.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.19.0
3
+ Version: 0.20.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.19.0",
3
+ "version": "0.20.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "strictcli",
9
- "version": "0.19.0",
9
+ "version": "0.20.0",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.19.0",
3
+ "version": "0.20.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.19.0"
7
+ version = "0.20.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.19.0"
5
+ __version__ = "0.20.0"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
@@ -252,7 +252,7 @@ def _config_typename(value: object) -> str:
252
252
  return type(value).__name__
253
253
 
254
254
 
255
- _CHECK_NAME_RE = re.compile(r"^[a-z][a-z0-9-]*$")
255
+ _IDENTIFIER_RE = re.compile(r"^[a-z][a-z0-9-]*$")
256
256
  _CHECK_REQUIRED_FIELDS = {"tags", "severity", "fast", "pure", "needs_network", "depends_on"}
257
257
  _CHECK_VALID_SEVERITIES = {"error", "warn"}
258
258
 
@@ -290,7 +290,7 @@ def _parse_checks_toml(data: bytes) -> tuple[str, dict[str, _CheckDef]]:
290
290
 
291
291
  for name, fields in checks_section.items():
292
292
  # Validate check name
293
- if not _CHECK_NAME_RE.match(name):
293
+ if not _IDENTIFIER_RE.match(name):
294
294
  raise ValueError(
295
295
  f'checks.toml: invalid check name "{name}" '
296
296
  f"(must match [a-z][a-z0-9-]*)"
@@ -731,22 +731,26 @@ class DeprecatedCommand:
731
731
  message: str
732
732
 
733
733
 
734
- @dataclass
734
+ @dataclass(frozen=True)
735
735
  class Command:
736
736
  """A leaf command with a handler."""
737
737
 
738
738
  name: str
739
739
  help: str
740
740
  handler: Callable | None
741
- flags: list[Flag] = field(default_factory=list)
742
- args: list[Arg] = field(default_factory=list)
743
- flag_sets: list[FlagSet] = field(default_factory=list)
744
- mutex: list[MutexGroup] = field(default_factory=list)
745
- dependencies: list[CoRequired | Requires | Implies] = field(default_factory=list)
741
+ flags: tuple[Flag, ...] = ()
742
+ args: tuple[Arg, ...] = ()
743
+ flag_sets: tuple[FlagSet, ...] = ()
744
+ mutex: tuple[MutexGroup, ...] = ()
745
+ dependencies: tuple[CoRequired | Requires | Implies, ...] = ()
746
746
  passthrough: Passthrough | None = None
747
+ tags: frozenset[str] = frozenset()
747
748
 
748
749
  def __post_init__(self) -> None:
749
750
  _require_non_empty_str(self.help, "help", "Command")
751
+ for tag in self.tags:
752
+ if not _IDENTIFIER_RE.match(tag):
753
+ raise ValueError(f'invalid tag name "{tag}": must match [a-z][a-z0-9-]*')
750
754
 
751
755
 
752
756
  @dataclass
@@ -760,11 +764,16 @@ class Group:
760
764
  deprecated: dict[str, DeprecatedCommand] = field(default_factory=dict)
761
765
  env_prefix: str | None = None
762
766
  _global_flags: list[Flag] = field(default_factory=list)
767
+ tags: frozenset[str] = frozenset()
768
+ _accumulated_tags: frozenset[str] = frozenset()
763
769
 
764
770
  def __post_init__(self) -> None:
765
771
  _require_non_empty_str(self.help, "help", "Group")
772
+ for tag in self.tags:
773
+ if not _IDENTIFIER_RE.match(tag):
774
+ raise ValueError(f'invalid tag name "{tag}": must match [a-z][a-z0-9-]*')
766
775
 
767
- def group(self, name: str, *, help: str) -> Group:
776
+ def group(self, name: str, *, help: str, tags: set[str] | None = None) -> Group:
768
777
  """Create and register a child subgroup."""
769
778
  if name in self.commands:
770
779
  raise ValueError(
@@ -774,8 +783,11 @@ class Group:
774
783
  raise ValueError(
775
784
  f'group "{name}" is already registered'
776
785
  )
786
+ own_tags = frozenset(tags or set())
777
787
  grp = Group(name=name, help=help, env_prefix=self.env_prefix,
778
- _global_flags=self._global_flags)
788
+ _global_flags=self._global_flags,
789
+ tags=own_tags,
790
+ _accumulated_tags=self._accumulated_tags | own_tags)
779
791
  self._groups[name] = grp
780
792
  return grp
781
793
 
@@ -809,6 +821,7 @@ class Group:
809
821
  mutex: list[MutexGroup] | None = None,
810
822
  dependencies: list[CoRequired | Requires | Implies] | None = None,
811
823
  passthrough: Passthrough | None = None,
824
+ tags: set[str] | None = None,
812
825
  ) -> Callable:
813
826
  """Decorator to register a command within this group."""
814
827
 
@@ -823,6 +836,8 @@ class Group:
823
836
  env_prefix=self.env_prefix,
824
837
  global_flags=self._global_flags,
825
838
  passthrough=passthrough,
839
+ tags=tags,
840
+ inherited_tags=self._accumulated_tags,
826
841
  )
827
842
  self.commands[name] = cmd
828
843
  return func
@@ -961,6 +976,8 @@ class App:
961
976
  self._check_defs: dict[str, _CheckDef] = {}
962
977
  self._checks_enabled = False
963
978
 
979
+ self._tag_contracts: dict[str, str] = {}
980
+
964
981
  @property
965
982
  def config_file_path(self) -> str:
966
983
  """Return the resolved config file path for this app."""
@@ -1003,6 +1020,50 @@ class App:
1003
1020
  )
1004
1021
  return None
1005
1022
 
1023
+ def tag_contract(self, tag: str, *, requires_flag: str) -> None:
1024
+ """Declare that any command with the given tag must have the named flag."""
1025
+ if not _IDENTIFIER_RE.match(tag):
1026
+ raise ValueError(f'invalid tag name "{tag}": must match [a-z][a-z0-9-]*')
1027
+ self._tag_contracts[tag] = requires_flag
1028
+
1029
+ def _validate_tag_contracts(self) -> str | None:
1030
+ """Check that all tag contracts are satisfied.
1031
+
1032
+ Returns an error message if any command violates a contract, or None.
1033
+ """
1034
+ if not self._tag_contracts:
1035
+ return None
1036
+
1037
+ def _check_commands(commands: dict) -> str | None:
1038
+ for cmd in commands.values():
1039
+ if cmd.passthrough is not None:
1040
+ continue
1041
+ for tag in cmd.tags:
1042
+ if tag in self._tag_contracts:
1043
+ required_flag = self._tag_contracts[tag]
1044
+ flag_names = {f.name for f in cmd.flags}
1045
+ if required_flag not in flag_names:
1046
+ return (
1047
+ f'command "{cmd.name}": tag "{tag}" requires '
1048
+ f'flag "--{required_flag}"'
1049
+ )
1050
+ return None
1051
+
1052
+ def _check_groups(groups: dict) -> str | None:
1053
+ for group in groups.values():
1054
+ err = _check_commands(group.commands)
1055
+ if err:
1056
+ return err
1057
+ err = _check_groups(group._groups)
1058
+ if err:
1059
+ return err
1060
+ return None
1061
+
1062
+ err = _check_commands(self._commands)
1063
+ if err:
1064
+ return err
1065
+ return _check_groups(self._groups)
1066
+
1006
1067
  def set_check_context(self, factory: Callable) -> None:
1007
1068
  """Set the factory function that creates CheckContext for check runs.
1008
1069
 
@@ -1144,6 +1205,7 @@ class App:
1144
1205
  mutex: list[MutexGroup] | None = None,
1145
1206
  dependencies: list[CoRequired | Requires | Implies] | None = None,
1146
1207
  passthrough: Passthrough | None = None,
1208
+ tags: set[str] | None = None,
1147
1209
  ) -> Callable:
1148
1210
  """Decorator to register a top-level command."""
1149
1211
 
@@ -1159,16 +1221,21 @@ class App:
1159
1221
  env_prefix=self.env_prefix,
1160
1222
  global_flags=self._global_flags,
1161
1223
  passthrough=passthrough,
1224
+ tags=tags,
1225
+ inherited_tags=None,
1162
1226
  )
1163
1227
  self._commands[name] = cmd
1164
1228
  return func
1165
1229
 
1166
1230
  return decorator
1167
1231
 
1168
- def group(self, name: str, *, help: str) -> Group:
1232
+ def group(self, name: str, *, help: str, tags: set[str] | None = None) -> Group:
1169
1233
  """Create and register a command group."""
1234
+ own_tags = frozenset(tags or set())
1170
1235
  grp = Group(name=name, help=help, env_prefix=self.env_prefix,
1171
- _global_flags=self._global_flags)
1236
+ _global_flags=self._global_flags,
1237
+ tags=own_tags,
1238
+ _accumulated_tags=own_tags)
1172
1239
  self._groups[name] = grp
1173
1240
  return grp
1174
1241
 
@@ -1288,8 +1355,8 @@ class App:
1288
1355
  name="show",
1289
1356
  help="Show all config values with source attribution",
1290
1357
  handler=_config_show_handler,
1291
- flags=config_show_flags,
1292
- mutex=config_show_mutex,
1358
+ flags=tuple(config_show_flags),
1359
+ mutex=tuple(config_show_mutex),
1293
1360
  )
1294
1361
 
1295
1362
  # config set
@@ -1440,18 +1507,18 @@ class App:
1440
1507
  name="set",
1441
1508
  help="Set a config value",
1442
1509
  handler=_config_set_handler,
1443
- args=[
1510
+ args=(
1444
1511
  Arg(name="key", help="Config key to set"),
1445
1512
  Arg(name="value",
1446
1513
  help="Value to set (comma-separated for repeatable flags, use backslash to escape commas)",
1447
1514
  required=False),
1448
- ],
1449
- flags=[
1515
+ ),
1516
+ flags=(
1450
1517
  Flag(name="clear", type=bool,
1451
1518
  help="Clear a repeatable flag (set to empty list)"),
1452
1519
  Flag(name="default", type=bool,
1453
1520
  help="Reset a key to its default (remove from config)"),
1454
- ],
1521
+ ),
1455
1522
  )
1456
1523
 
1457
1524
  # config edit
@@ -1924,6 +1991,10 @@ class App:
1924
1991
  if check_err:
1925
1992
  print(f"error: {check_err}", file=sys.stderr)
1926
1993
  sys.exit(1)
1994
+ tag_err = self._validate_tag_contracts()
1995
+ if tag_err:
1996
+ print(f"error: {tag_err}", file=sys.stderr)
1997
+ sys.exit(1)
1927
1998
  argv = sys.argv[1:]
1928
1999
  try:
1929
2000
  cmd, data = self._parse(argv)
@@ -1974,6 +2045,15 @@ class App:
1974
2045
  exit_code=1,
1975
2046
  )
1976
2047
 
2048
+ tag_err = self._validate_tag_contracts()
2049
+ if tag_err:
2050
+ stderr_buf.write(f"error: {tag_err}\n")
2051
+ return Result(
2052
+ stdout=stdout_buf.getvalue(),
2053
+ stderr=stderr_buf.getvalue(),
2054
+ exit_code=1,
2055
+ )
2056
+
1977
2057
  try:
1978
2058
  cmd, data = self._parse(argv)
1979
2059
  except _HelpRequested as e:
@@ -2490,11 +2570,15 @@ def _build_and_validate_command(
2490
2570
  global_flags: list[Flag] | None = None,
2491
2571
  passthrough: Passthrough | None = None,
2492
2572
  extra_flags: list[Flag] | None = None,
2573
+ tags: set[str] | None = None,
2574
+ inherited_tags: frozenset[str] | None = None,
2493
2575
  ) -> Command:
2494
2576
  """Build a Command from a decorated handler, validate everything."""
2495
2577
  if not help or not help.strip():
2496
2578
  raise ValueError(f'command "{name}": missing help text')
2497
2579
 
2580
+ effective_tags = (inherited_tags or frozenset()) | frozenset(tags or set())
2581
+
2498
2582
  # Passthrough commands must not have flags, args, flag sets, or mutex groups
2499
2583
  if passthrough is not None:
2500
2584
  decorator_flags = list(getattr(handler, "_strictcli_flags", []))
@@ -2522,6 +2606,7 @@ def _build_and_validate_command(
2522
2606
  help=help,
2523
2607
  handler=None,
2524
2608
  passthrough=passthrough,
2609
+ tags=effective_tags,
2525
2610
  )
2526
2611
 
2527
2612
  # Collect flags attached by @strictcli.flag decorators
@@ -2743,11 +2828,12 @@ def _build_and_validate_command(
2743
2828
  name=name,
2744
2829
  help=help,
2745
2830
  handler=handler,
2746
- flags=all_flags,
2747
- args=all_args,
2748
- flag_sets=resolved_flag_sets,
2749
- mutex=resolved_mutex,
2750
- dependencies=resolved_dependencies,
2831
+ flags=tuple(all_flags),
2832
+ args=tuple(all_args),
2833
+ flag_sets=tuple(resolved_flag_sets),
2834
+ mutex=tuple(resolved_mutex),
2835
+ dependencies=tuple(resolved_dependencies),
2836
+ tags=effective_tags,
2751
2837
  )
2752
2838
 
2753
2839
 
@@ -3598,6 +3684,9 @@ def _serialize_command(cmd: Command) -> dict:
3598
3684
  args = [_serialize_arg(a) for a in cmd.args]
3599
3685
  if args:
3600
3686
  d["args"] = args
3687
+ tags = sorted(cmd.tags)
3688
+ if tags:
3689
+ d["tags"] = tags
3601
3690
  return d
3602
3691
 
3603
3692
 
@@ -3620,6 +3709,9 @@ def _serialize_group(group: Group) -> dict:
3620
3709
  deprecated = {name: dep.message for name, dep in group.deprecated.items()}
3621
3710
  if deprecated:
3622
3711
  d["deprecated"] = deprecated
3712
+ tags = sorted(group.tags)
3713
+ if tags:
3714
+ d["tags"] = tags
3623
3715
  return d
3624
3716
 
3625
3717
 
@@ -3653,11 +3745,13 @@ def _build_schema_defaults() -> dict:
3653
3745
  "passthrough": False,
3654
3746
  "flags": [],
3655
3747
  "args": [],
3748
+ "tags": [],
3656
3749
  },
3657
3750
  "group": {
3658
3751
  "commands": {},
3659
3752
  "groups": {},
3660
3753
  "deprecated": {},
3754
+ "tags": [],
3661
3755
  },
3662
3756
  }
3663
3757