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.
- strictcli-0.15.0/.rlsbl/changes/.validated +1 -0
- strictcli-0.15.0/.rlsbl/changes/0.15.0.jsonl +6 -0
- strictcli-0.15.0/.rlsbl/releases/unreleased.toml +0 -0
- strictcli-0.15.0/.rlsbl/releases/v0.15.0.toml +8 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/CHANGELOG.md +13 -3
- {strictcli-0.14.0 → strictcli-0.15.0}/PKG-INFO +1 -1
- {strictcli-0.14.0 → strictcli-0.15.0}/package-lock.json +2 -2
- {strictcli-0.14.0 → strictcli-0.15.0}/package.json +1 -1
- {strictcli-0.14.0 → strictcli-0.15.0}/pyproject.toml +1 -1
- {strictcli-0.14.0 → strictcli-0.15.0}/strictcli/__init__.py +63 -27
- strictcli-0.15.0/tests/test_config_set_bugs.py +242 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/uv.lock +1 -1
- strictcli-0.14.0/.rlsbl/changes/.validated +0 -1
- {strictcli-0.14.0 → strictcli-0.15.0}/.claude/settings.json +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.github/workflows/ci.yml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.github/workflows/publish.yml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.gitignore +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.npmignore +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.claude/settings.json +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.gitignore +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.npmignore +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/hooks/post-release.sh +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/lint/go.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/lint/npm.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/CHANGELOG.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/CLAUDE.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/bases/LICENSE +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.10.0.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.10.0.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.11.0.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.11.0.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.12.0.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.12.0.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.13.0.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.13.0.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.14.0.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.14.0.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.4.0.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.4.0.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.4.1.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.4.1.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.5.0.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.5.0.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.6.0.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.6.0.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.6.1.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.6.1.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.7.0.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.7.0.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.7.1.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.7.1.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.0.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.0.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.1.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.1.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.2.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.2.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.3.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.3.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.4.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.4.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.5.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.5.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.6.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.6.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.7.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.8.7.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.9.0.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.9.0.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.9.1.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/0.9.1.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/changes/unreleased.jsonl +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/config.json +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/hashes.json +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/hooks/post-release.sh +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/hooks/pre-checks.sh +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/hooks/pre-release.sh +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/lint/go.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/lint/npm.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.10.0.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.11.0.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.12.0.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.13.0.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.8.5.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.8.6.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.8.7.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.9.0.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/releases/v0.9.1.toml +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.rlsbl/version +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/.strictcli/schema.json +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/CLAUDE.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/LICENSE +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/README.md +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/index.js +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/postinstall.js +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_arg_default.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_at_prefix.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_auto_version.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_check_command.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_check_discovery.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_check_runner.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_check_schema.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_check_types.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_choices.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_config.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_config_file_path.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_dependencies.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_deprecated.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_dump_schema.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_e2e.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_env.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_exit_codes.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_float_type.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_global_flags.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_help.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_int_type.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_mutex.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_nesting.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_parser.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_passthrough.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_registration.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_repeatable.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_tagdsl.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_tags.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_toml_loading.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_validate.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/tests/test_variadic.py +0 -0
- {strictcli-0.14.0 → strictcli-0.15.0}/todo/.defer/deferred.md +0 -0
- {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.
|
|
5
|
+
## 0.15.0
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Config set type coercion and key validation
|
|
8
8
|
|
|
9
9
|
<details>
|
|
10
10
|
<summary>Context</summary>
|
|
11
11
|
|
|
12
|
-
|
|
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,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "strictcli",
|
|
3
|
-
"version": "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.
|
|
9
|
+
"version": "0.15.0",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT"
|
|
12
12
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
__version__ = "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) ->
|
|
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
|
|
1027
|
-
|
|
1028
|
-
|
|
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
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
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
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
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
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
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)
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|