strictcli 0.8.7__tar.gz → 0.9.1__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 (118) hide show
  1. strictcli-0.9.1/.rlsbl/changes/.validated +1 -0
  2. strictcli-0.9.1/.rlsbl/changes/0.9.0.jsonl +5 -0
  3. strictcli-0.9.1/.rlsbl/changes/0.9.0.md +5 -0
  4. strictcli-0.9.1/.rlsbl/changes/0.9.1.jsonl +9 -0
  5. strictcli-0.9.1/.rlsbl/changes/0.9.1.md +9 -0
  6. strictcli-0.9.1/.rlsbl/releases/v0.9.0.toml +3 -0
  7. strictcli-0.9.1/.rlsbl/releases/v0.9.1.toml +3 -0
  8. strictcli-0.9.1/.rlsbl/version +1 -0
  9. {strictcli-0.8.7 → strictcli-0.9.1}/CHANGELOG.md +16 -0
  10. {strictcli-0.8.7 → strictcli-0.9.1}/PKG-INFO +1 -1
  11. {strictcli-0.8.7 → strictcli-0.9.1}/package-lock.json +2 -2
  12. {strictcli-0.8.7 → strictcli-0.9.1}/package.json +1 -1
  13. {strictcli-0.8.7 → strictcli-0.9.1}/pyproject.toml +1 -1
  14. {strictcli-0.8.7 → strictcli-0.9.1}/strictcli/__init__.py +111 -23
  15. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_config.py +325 -0
  16. strictcli-0.9.1/tests/test_config_file_path.py +60 -0
  17. {strictcli-0.8.7 → strictcli-0.9.1}/uv.lock +1 -1
  18. strictcli-0.8.7/.rlsbl/changes/.validated +0 -1
  19. strictcli-0.8.7/.rlsbl/version +0 -1
  20. {strictcli-0.8.7 → strictcli-0.9.1}/.claude/settings.json +0 -0
  21. {strictcli-0.8.7 → strictcli-0.9.1}/.github/workflows/ci.yml +0 -0
  22. {strictcli-0.8.7 → strictcli-0.9.1}/.github/workflows/publish.yml +0 -0
  23. {strictcli-0.8.7 → strictcli-0.9.1}/.gitignore +0 -0
  24. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/.claude/settings.json +0 -0
  25. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  26. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  27. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/.gitignore +0 -0
  28. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/.rlsbl/hooks/post-release.sh +0 -0
  29. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +0 -0
  30. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +0 -0
  31. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/.rlsbl/lint/go.toml +0 -0
  32. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/.rlsbl/lint/npm.toml +0 -0
  33. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  34. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/CHANGELOG.md +0 -0
  35. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/CLAUDE.md +0 -0
  36. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/bases/LICENSE +0 -0
  37. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.4.0.jsonl +0 -0
  38. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.4.0.md +0 -0
  39. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.4.1.jsonl +0 -0
  40. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.4.1.md +0 -0
  41. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.5.0.jsonl +0 -0
  42. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.5.0.md +0 -0
  43. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.6.0.jsonl +0 -0
  44. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.6.0.md +0 -0
  45. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.6.1.jsonl +0 -0
  46. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.6.1.md +0 -0
  47. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.7.0.jsonl +0 -0
  48. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.7.0.md +0 -0
  49. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.7.1.jsonl +0 -0
  50. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.7.1.md +0 -0
  51. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.0.jsonl +0 -0
  52. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.0.md +0 -0
  53. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.1.jsonl +0 -0
  54. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.1.md +0 -0
  55. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.2.jsonl +0 -0
  56. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.2.md +0 -0
  57. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.3.jsonl +0 -0
  58. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.3.md +0 -0
  59. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.4.jsonl +0 -0
  60. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.4.md +0 -0
  61. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.5.jsonl +0 -0
  62. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.5.md +0 -0
  63. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.6.jsonl +0 -0
  64. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.6.md +0 -0
  65. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.7.jsonl +0 -0
  66. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/0.8.7.md +0 -0
  67. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/changes/unreleased.jsonl +0 -0
  68. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/config.json +0 -0
  69. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/hashes.json +0 -0
  70. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/hooks/post-release.sh +0 -0
  71. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/hooks/pre-checks.sh +0 -0
  72. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/hooks/pre-release.sh +0 -0
  73. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/lint/go.toml +0 -0
  74. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/lint/npm.toml +0 -0
  75. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/lint/python.toml +0 -0
  76. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/releases/unreleased.toml +0 -0
  77. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/releases/v0.8.5.toml +0 -0
  78. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/releases/v0.8.6.toml +0 -0
  79. {strictcli-0.8.7 → strictcli-0.9.1}/.rlsbl/releases/v0.8.7.toml +0 -0
  80. {strictcli-0.8.7 → strictcli-0.9.1}/.strictcli/schema.json +0 -0
  81. {strictcli-0.8.7 → strictcli-0.9.1}/CLAUDE.md +0 -0
  82. {strictcli-0.8.7 → strictcli-0.9.1}/LICENSE +0 -0
  83. {strictcli-0.8.7 → strictcli-0.9.1}/README.md +0 -0
  84. {strictcli-0.8.7 → strictcli-0.9.1}/index.js +0 -0
  85. {strictcli-0.8.7 → strictcli-0.9.1}/postinstall.js +0 -0
  86. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_arg_default.py +0 -0
  87. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_auto_version.py +0 -0
  88. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_check_command.py +0 -0
  89. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_check_discovery.py +0 -0
  90. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_check_runner.py +0 -0
  91. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_check_schema.py +0 -0
  92. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_check_types.py +0 -0
  93. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_choices.py +0 -0
  94. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_command_help_suggestion.py +0 -0
  95. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_deep_nesting.py +0 -0
  96. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_dependencies.py +0 -0
  97. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_deprecated.py +0 -0
  98. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_dump_schema.py +0 -0
  99. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_e2e.py +0 -0
  100. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_env.py +0 -0
  101. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_exit_codes.py +0 -0
  102. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_float_type.py +0 -0
  103. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_global_flags.py +0 -0
  104. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_help.py +0 -0
  105. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_int_type.py +0 -0
  106. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_mutex.py +0 -0
  107. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_nesting.py +0 -0
  108. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_parser.py +0 -0
  109. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_passthrough.py +0 -0
  110. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_registration.py +0 -0
  111. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_repeatable.py +0 -0
  112. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_tagdsl.py +0 -0
  113. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_tags.py +0 -0
  114. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_toml_loading.py +0 -0
  115. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_validate.py +0 -0
  116. {strictcli-0.8.7 → strictcli-0.9.1}/tests/test_variadic.py +0 -0
  117. {strictcli-0.8.7 → strictcli-0.9.1}/todo/.defer/deferred.md +0 -0
  118. {strictcli-0.8.7 → strictcli-0.9.1}/todo/.done/original-idea.md +0 -0
@@ -0,0 +1 @@
1
+ 4a6d209ab6bd677356d90300dcb5d073d2d73935
@@ -0,0 +1,5 @@
1
+ {"commits":["e595eaf58390d6b941b0362dfcbc65b9016054a9"],"user_facing":false}
2
+ {"commits":["36342216953c20b8f38c9517e4eebad0893d1718"],"user_facing":false}
3
+ {"commits":["fad001e35420a4aa0bd019a68448ce4c970d3f1e"],"user_facing":false}
4
+ {"commits":["bb95ff55342e50eeec9a20c417b96e76df99e9c0"],"user_facing":true,"description":"**New feature.** config_path and config_format options for TOML-based configuration file support.","type":"feature"}
5
+ {"commits":["0f0524d47831254eb0579b55ea14aff204043546"],"user_facing":false}
@@ -0,0 +1,5 @@
1
+ ## 0.9.0
2
+
3
+ ### Features
4
+
5
+ - **New feature.** config_path and config_format options for TOML-based configuration file support.
@@ -0,0 +1,9 @@
1
+ {"commits":["034d46ccdb83e2e6abdfb27d50553128e8b194da"],"user_facing":true,"description":"**New feature.** Public `config_file_path` property on App.","type":"feature"}
2
+ {"commits":["0eacbbb26047b737ac9ff5a05ccc25f381c6917f"],"user_facing":true,"description":"**Fix.** Config format validation errors now have parity between Python and Go.","type":"fix"}
3
+ {"commits":["a40e7421f7e51240f51c4a8ce9ed72e73af2dd94","3d0745ec3da5d124465ae62a6edda1ba9c586909","e1512089774da17d8e1b50b38af9e00f2b04f401","06ff387b1cf6e867e5cf926ad02c646f4020cd4d","f53fa79fb38d1d7ace07305c06030800fe8173bc"],"user_facing":false}
4
+ {"commits":["052392699e08e4edf48d688f244241c461d7eef7","4c64feea9c5efed933f46950a66985707d287c2c","a3058311994565626d720afb34f95f9230828500","2732196f195b787c8c1785c1d054e31d81be73f6","76e89079ca555173f31e16868a8f0e9da8dd86e9"],"user_facing":false}
5
+ {"commits":["81cfebb6de9674be9fafc0756c19d0dd8a6a29c1","b2317bcce29fdeabb0726e7db6865a917d596d91","0bc0c1f80c5252846a685954052690451676815d","905a378a3360abb1fd00fb009862a2d1875f388c"],"user_facing":false}
6
+ {"commits":["77050e3cdb3cdd396bf83fee87768342903157f7"],"user_facing":false}
7
+ {"commits":["2eb65bb7d36320cf1e398e42fa43a63e2d771042","9276de627df40c14d0a0f7f4cd6f934323d7ee47"],"user_facing":false}
8
+ {"commits":["7ccae37d21414a46c87167f726c2279e61db6b09"],"user_facing":false}
9
+ {"commits":["1179ab585f13a63254148ef67705df81ff07b99d"],"user_facing":false}
@@ -0,0 +1,9 @@
1
+ ## 0.9.1
2
+
3
+ ### Features
4
+
5
+ - **New feature.** Public `config_file_path` property on App.
6
+
7
+ ### Fixes
8
+
9
+ - **Fix.** Config format validation errors now have parity between Python and Go.
@@ -0,0 +1,3 @@
1
+ bump = "minor"
2
+ include = ["pypi", "npm"]
3
+ exclude = []
@@ -0,0 +1,3 @@
1
+ bump = "patch"
2
+ include = ["pypi", "npm"]
3
+ exclude = []
@@ -0,0 +1 @@
1
+ 0.41.5
@@ -2,6 +2,22 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## 0.9.1
6
+
7
+ ### Features
8
+
9
+ - **New feature.** Public `config_file_path` property on App.
10
+
11
+ ### Fixes
12
+
13
+ - **Fix.** Config format validation errors now have parity between Python and Go.
14
+
15
+ ## 0.9.0
16
+
17
+ ### Features
18
+
19
+ - **New feature.** config_path and config_format options for TOML-based configuration file support.
20
+
5
21
  ## 0.8.7
6
22
 
7
23
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.8.7
3
+ Version: 0.9.1
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.8.7",
3
+ "version": "0.9.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "strictcli",
9
- "version": "0.8.7",
9
+ "version": "0.9.1",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.8.7",
3
+ "version": "0.9.1",
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.8.7"
7
+ version = "0.9.1"
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.8.7"
5
+ __version__ = "0.9.1"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "Tag", "MutexGroup", "CoRequired", "Requires",
@@ -36,21 +36,40 @@ class _MissingSentinel:
36
36
  _MISSING = _MissingSentinel()
37
37
 
38
38
 
39
- def _config_path(app_name: str) -> str:
40
- """Compute the config file path for an app."""
39
+ def _config_path(app_name: str, *, override: str | None = None, config_format: str = "json") -> str:
40
+ """Compute the config file path for an app.
41
+
42
+ If override is provided, expand ~ and return it directly.
43
+ Otherwise compute from XDG_CONFIG_HOME + app_name.
44
+ """
45
+ if override is not None:
46
+ return os.path.expanduser(override)
41
47
  config_home = os.environ.get("XDG_CONFIG_HOME", os.path.expanduser("~/.config"))
42
- return os.path.join(config_home, app_name, "config.json")
48
+ ext = "toml" if config_format == "toml" else "json"
49
+ return os.path.join(config_home, app_name, f"config.{ext}")
43
50
 
44
51
 
45
- def _load_config(app_name: str) -> dict:
46
- """Load the JSON config file for an app.
52
+ def _load_config(
53
+ app_name: str,
54
+ *,
55
+ config_path_override: str | None = None,
56
+ config_format: str = "json",
57
+ ) -> dict:
58
+ """Load the config file for an app.
47
59
 
48
- Returns an empty dict if the file doesn't exist or contains invalid JSON.
49
- Invalid JSON prints a warning to stderr.
60
+ Returns an empty dict if the file doesn't exist or contains invalid content.
61
+ Invalid content prints a warning to stderr.
50
62
  """
51
- path = _config_path(app_name)
63
+ path = _config_path(app_name, override=config_path_override, config_format=config_format)
52
64
  if not os.path.isfile(path):
53
65
  return {}
66
+ if config_format == "toml":
67
+ try:
68
+ with open(path, "rb") as f:
69
+ return tomllib.load(f)
70
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError):
71
+ print(f"warning: invalid TOML in config file '{path}', ignoring", file=sys.stderr)
72
+ return {}
54
73
  try:
55
74
  with open(path) as f:
56
75
  return json.loads(f.read())
@@ -59,6 +78,29 @@ def _load_config(app_name: str) -> dict:
59
78
  return {}
60
79
 
61
80
 
81
+ def _write_toml_flat(data: dict, path: str) -> None:
82
+ """Write a flat dict as a TOML file.
83
+
84
+ Supports str, int, float, and bool values. This avoids requiring
85
+ a TOML writer dependency for the simple key=value configs that
86
+ 'config set' produces.
87
+ """
88
+ lines: list[str] = []
89
+ for key, value in data.items():
90
+ if isinstance(value, bool):
91
+ lines.append(f"{key} = {str(value).lower()}")
92
+ elif isinstance(value, str):
93
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
94
+ lines.append(f'{key} = "{escaped}"')
95
+ elif isinstance(value, (int, float)):
96
+ lines.append(f"{key} = {value}")
97
+ else:
98
+ escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
99
+ lines.append(f'{key} = "{escaped}"')
100
+ with open(path, "w") as f:
101
+ f.write("\n".join(lines) + "\n" if lines else "")
102
+
103
+
62
104
  def _coerce_config_value(value: object, flag: "Flag") -> object:
63
105
  """Coerce a JSON config value to the flag's type.
64
106
 
@@ -579,6 +621,8 @@ class App:
579
621
  version: str | None = None
580
622
  env_prefix: str | None = None
581
623
  config: bool = False
624
+ config_path: str | None = None
625
+ config_format: str = "json"
582
626
  flags: list[Flag] = field(default_factory=list)
583
627
  _commands: dict[str, Command] = field(default_factory=dict)
584
628
  _groups: dict[str, Group] = field(default_factory=dict)
@@ -600,10 +644,19 @@ class App:
600
644
  seen.add(f.name)
601
645
  self._global_flags: list[Flag] = list(self.flags)
602
646
  self._last_global_values: dict[str, object] = {}
647
+ # Validate config_format
648
+ if self.config_format not in ("json", "toml"):
649
+ raise ValueError(
650
+ f'App.config_format must be "json" or "toml", got {self.config_format!r}'
651
+ )
603
652
  # Load config and register config subcommands if enabled
604
653
  self._config_data: dict = {}
605
654
  if self.config:
606
- self._config_data = _load_config(self.name)
655
+ self._config_data = _load_config(
656
+ self.name,
657
+ config_path_override=self.config_path,
658
+ config_format=self.config_format,
659
+ )
607
660
  self._register_config_group()
608
661
  # Discover checks TOML
609
662
  self._check_context_factory: Callable | None = None
@@ -616,6 +669,11 @@ class App:
616
669
  self._check_defs = {}
617
670
  self._checks_enabled = False
618
671
 
672
+ @property
673
+ def config_file_path(self) -> str:
674
+ """Return the resolved config file path for this app."""
675
+ return _config_path(self.name, override=self.config_path, config_format=self.config_format)
676
+
619
677
  def check(self, name: str):
620
678
  """Decorator to register a check implementation."""
621
679
  def decorator(fn):
@@ -843,12 +901,20 @@ class App:
843
901
  config_grp.commands["path"] = Command(
844
902
  name="path",
845
903
  help="Print the config file path",
846
- handler=lambda **_kw: print(_config_path(app_ref.name)),
904
+ handler=lambda **_kw: print(_config_path(
905
+ app_ref.name,
906
+ override=app_ref.config_path,
907
+ config_format=app_ref.config_format,
908
+ )),
847
909
  )
848
910
 
849
911
  # config show
850
912
  def _config_show_handler(**_kw) -> None:
851
- config_data = _load_config(app_ref.name)
913
+ config_data = _load_config(
914
+ app_ref.name,
915
+ config_path_override=app_ref.config_path,
916
+ config_format=app_ref.config_format,
917
+ )
852
918
  all_flags = app_ref._collect_all_flags()
853
919
  for f in all_flags:
854
920
  param = _flag_param_name(f.name)
@@ -872,20 +938,34 @@ class App:
872
938
 
873
939
  # config set
874
940
  def _config_set_handler(key, value, **_kw) -> None:
875
- path = _config_path(app_ref.name)
941
+ path = _config_path(
942
+ app_ref.name,
943
+ override=app_ref.config_path,
944
+ config_format=app_ref.config_format,
945
+ )
876
946
  dir_path = os.path.dirname(path)
877
947
  os.makedirs(dir_path, exist_ok=True)
878
948
  # Read existing config
879
949
  existing: dict = {}
880
950
  if os.path.isfile(path):
881
- try:
882
- with open(path) as fh:
883
- existing = json.loads(fh.read())
884
- except (json.JSONDecodeError, ValueError):
885
- existing = {}
951
+ if app_ref.config_format == "toml":
952
+ try:
953
+ with open(path, "rb") as fh:
954
+ existing = tomllib.load(fh)
955
+ except (tomllib.TOMLDecodeError, UnicodeDecodeError):
956
+ existing = {}
957
+ else:
958
+ try:
959
+ with open(path) as fh:
960
+ existing = json.loads(fh.read())
961
+ except (json.JSONDecodeError, ValueError):
962
+ existing = {}
886
963
  existing[key] = value
887
- with open(path, "w") as fh:
888
- fh.write(json.dumps(existing, indent=2) + "\n")
964
+ if app_ref.config_format == "toml":
965
+ _write_toml_flat(existing, path)
966
+ else:
967
+ with open(path, "w") as fh:
968
+ fh.write(json.dumps(existing, indent=2) + "\n")
889
969
 
890
970
  config_grp.commands["set"] = Command(
891
971
  name="set",
@@ -899,12 +979,20 @@ class App:
899
979
 
900
980
  # config edit
901
981
  def _config_edit_handler(**_kw) -> None:
902
- path = _config_path(app_ref.name)
982
+ path = _config_path(
983
+ app_ref.name,
984
+ override=app_ref.config_path,
985
+ config_format=app_ref.config_format,
986
+ )
903
987
  dir_path = os.path.dirname(path)
904
988
  os.makedirs(dir_path, exist_ok=True)
905
989
  if not os.path.isfile(path):
906
- with open(path, "w") as fh:
907
- fh.write("{}\n")
990
+ if app_ref.config_format == "toml":
991
+ with open(path, "w") as fh:
992
+ fh.write("")
993
+ else:
994
+ with open(path, "w") as fh:
995
+ fh.write("{}\n")
908
996
  editor = os.environ.get("EDITOR", "vi")
909
997
  subprocess.run([editor, path])
910
998
 
@@ -376,3 +376,328 @@ def test_config_int_as_float(tmp_path, monkeypatch):
376
376
  r = app.test(["run"])
377
377
  assert r.exit_code == 0
378
378
  assert "ratio=2.0" in r.stdout
379
+
380
+
381
+ # --- Custom config_path tests ---
382
+
383
+ def test_custom_config_path(tmp_path, monkeypatch):
384
+ """Custom config_path is used instead of XDG-computed path."""
385
+ config_file = tmp_path / "my-custom-config.json"
386
+ config_file.write_text(json.dumps({"target": "custom-path-val"}) + "\n")
387
+
388
+ app = strictcli.App(
389
+ name="testapp",
390
+ version="1.0.0",
391
+ help="test app",
392
+ config=True,
393
+ config_path=str(config_file),
394
+ )
395
+
396
+ @app.command("run", help="run something")
397
+ @strictcli.flag("target", type=str, help="the target", default="default-val")
398
+ def run(target):
399
+ print(f"target={target}")
400
+
401
+ r = app.test(["run"])
402
+ assert r.exit_code == 0
403
+ assert "target=custom-path-val" in r.stdout
404
+
405
+
406
+ def test_custom_config_path_tilde_expansion(tmp_path, monkeypatch):
407
+ """Custom config_path expands ~ correctly."""
408
+ monkeypatch.setenv("HOME", str(tmp_path))
409
+ config_dir = tmp_path / ".myapp"
410
+ config_dir.mkdir()
411
+ config_file = config_dir / "settings.json"
412
+ config_file.write_text(json.dumps({"target": "tilde-val"}) + "\n")
413
+
414
+ app = strictcli.App(
415
+ name="testapp",
416
+ version="1.0.0",
417
+ help="test app",
418
+ config=True,
419
+ config_path="~/.myapp/settings.json",
420
+ )
421
+
422
+ @app.command("run", help="run something")
423
+ @strictcli.flag("target", type=str, help="the target", default="default-val")
424
+ def run(target):
425
+ print(f"target={target}")
426
+
427
+ r = app.test(["run"])
428
+ assert r.exit_code == 0
429
+ assert "target=tilde-val" in r.stdout
430
+
431
+
432
+ def test_custom_config_path_config_path_command(tmp_path):
433
+ """config path command prints the custom path."""
434
+ config_file = tmp_path / "custom.json"
435
+ config_file.write_text("{}")
436
+
437
+ app = strictcli.App(
438
+ name="testapp",
439
+ version="1.0.0",
440
+ help="test app",
441
+ config=True,
442
+ config_path=str(config_file),
443
+ )
444
+
445
+ @app.command("run", help="run something")
446
+ def run():
447
+ pass
448
+
449
+ r = app.test(["config", "path"])
450
+ assert r.exit_code == 0
451
+ assert str(config_file) in r.stdout
452
+
453
+
454
+ def test_custom_config_path_config_set(tmp_path):
455
+ """config set writes to the custom path."""
456
+ config_file = tmp_path / "custom.json"
457
+
458
+ app = strictcli.App(
459
+ name="testapp",
460
+ version="1.0.0",
461
+ help="test app",
462
+ config=True,
463
+ config_path=str(config_file),
464
+ )
465
+
466
+ @app.command("run", help="run something")
467
+ @strictcli.flag("target", type=str, help="the target", default="default-val")
468
+ def run(target):
469
+ print(f"target={target}")
470
+
471
+ r = app.test(["config", "set", "target", "written"])
472
+ assert r.exit_code == 0
473
+ assert config_file.exists()
474
+ data = json.loads(config_file.read_text())
475
+ assert data["target"] == "written"
476
+
477
+
478
+ # --- TOML config format tests ---
479
+
480
+ def test_toml_format_reads_correctly(tmp_path):
481
+ """TOML format config reads values correctly."""
482
+ config_file = tmp_path / "config.toml"
483
+ config_file.write_text('target = "toml-value"\ncount = 42\nverbose = true\n')
484
+
485
+ app = strictcli.App(
486
+ name="testapp",
487
+ version="1.0.0",
488
+ help="test app",
489
+ config=True,
490
+ config_path=str(config_file),
491
+ config_format="toml",
492
+ )
493
+
494
+ @app.command("run", help="run something")
495
+ @strictcli.flag("target", type=str, help="the target", default="default-val")
496
+ @strictcli.flag("count", type=int, help="how many", default=1)
497
+ @strictcli.flag("verbose", type=bool, help="be verbose")
498
+ def run(target, count, verbose):
499
+ print(f"target={target} count={count} verbose={verbose}")
500
+
501
+ r = app.test(["run"])
502
+ assert r.exit_code == 0
503
+ assert "target=toml-value" in r.stdout
504
+ assert "count=42" in r.stdout
505
+ assert "verbose=True" in r.stdout
506
+
507
+
508
+ def test_toml_format_set_writes_correctly(tmp_path):
509
+ """TOML format config set writes valid TOML."""
510
+ config_file = tmp_path / "config.toml"
511
+
512
+ app = strictcli.App(
513
+ name="testapp",
514
+ version="1.0.0",
515
+ help="test app",
516
+ config=True,
517
+ config_path=str(config_file),
518
+ config_format="toml",
519
+ )
520
+
521
+ @app.command("run", help="run something")
522
+ @strictcli.flag("target", type=str, help="the target", default="default-val")
523
+ def run(target):
524
+ print(f"target={target}")
525
+
526
+ r = app.test(["config", "set", "target", "toml-written"])
527
+ assert r.exit_code == 0
528
+ assert config_file.exists()
529
+
530
+ import tomllib
531
+ with open(config_file, "rb") as f:
532
+ data = tomllib.load(f)
533
+ assert data["target"] == "toml-written"
534
+
535
+
536
+ def test_toml_format_set_preserves_existing(tmp_path):
537
+ """TOML format config set preserves existing keys."""
538
+ config_file = tmp_path / "config.toml"
539
+ config_file.write_text('existing = "keep-me"\n')
540
+
541
+ app = strictcli.App(
542
+ name="testapp",
543
+ version="1.0.0",
544
+ help="test app",
545
+ config=True,
546
+ config_path=str(config_file),
547
+ config_format="toml",
548
+ )
549
+
550
+ @app.command("run", help="run something")
551
+ @strictcli.flag("target", type=str, help="the target", default="default-val")
552
+ def run(target):
553
+ print(f"target={target}")
554
+
555
+ r = app.test(["config", "set", "target", "new-val"])
556
+ assert r.exit_code == 0
557
+
558
+ import tomllib
559
+ with open(config_file, "rb") as f:
560
+ data = tomllib.load(f)
561
+ assert data["target"] == "new-val"
562
+ assert data["existing"] == "keep-me"
563
+
564
+
565
+ def test_toml_format_config_path_command(tmp_path):
566
+ """config path prints the custom path for TOML format."""
567
+ config_file = tmp_path / "my-config.toml"
568
+ config_file.write_text("")
569
+
570
+ app = strictcli.App(
571
+ name="testapp",
572
+ version="1.0.0",
573
+ help="test app",
574
+ config=True,
575
+ config_path=str(config_file),
576
+ config_format="toml",
577
+ )
578
+
579
+ @app.command("run", help="run something")
580
+ def run():
581
+ pass
582
+
583
+ r = app.test(["config", "path"])
584
+ assert r.exit_code == 0
585
+ assert str(config_file) in r.stdout
586
+
587
+
588
+ def test_toml_format_xdg_default_path(tmp_path, monkeypatch):
589
+ """Without custom config_path, TOML format uses .toml extension in XDG path."""
590
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
591
+
592
+ app = strictcli.App(
593
+ name="testapp",
594
+ version="1.0.0",
595
+ help="test app",
596
+ config=True,
597
+ config_format="toml",
598
+ )
599
+
600
+ @app.command("run", help="run something")
601
+ def run():
602
+ pass
603
+
604
+ r = app.test(["config", "path"])
605
+ assert r.exit_code == 0
606
+ expected = os.path.join(str(tmp_path), "testapp", "config.toml")
607
+ assert expected in r.stdout
608
+
609
+
610
+ def test_invalid_toml_warning(tmp_path):
611
+ """Invalid TOML file prints warning and falls back to defaults."""
612
+ config_file = tmp_path / "config.toml"
613
+ config_file.write_text("this is = not [ valid toml")
614
+
615
+ app = strictcli.App(
616
+ name="testapp",
617
+ version="1.0.0",
618
+ help="test app",
619
+ config=True,
620
+ config_path=str(config_file),
621
+ config_format="toml",
622
+ )
623
+
624
+ @app.command("run", help="run something")
625
+ @strictcli.flag("target", type=str, help="the target", default="default-val")
626
+ def run(target):
627
+ print(f"target={target}")
628
+
629
+ r = app.test(["run"])
630
+ assert r.exit_code == 0
631
+ assert "target=default-val" in r.stdout
632
+
633
+
634
+ def test_invalid_config_format():
635
+ """Invalid config_format raises ValueError."""
636
+ with pytest.raises(ValueError, match='config_format must be'):
637
+ strictcli.App(
638
+ name="testapp",
639
+ version="1.0.0",
640
+ help="test app",
641
+ config=True,
642
+ config_format="yaml",
643
+ )
644
+
645
+
646
+ def test_default_json_unchanged(tmp_path, monkeypatch):
647
+ """Default behavior (JSON, XDG path) is unchanged."""
648
+ config_home = _write_config(tmp_path, "testapp", {"target": "json-default"})
649
+ monkeypatch.setenv("XDG_CONFIG_HOME", config_home)
650
+ app = _make_config_app(config=True)
651
+ r = app.test(["run"])
652
+ assert r.exit_code == 0
653
+ assert "target=json-default" in r.stdout
654
+
655
+
656
+ def test_toml_config_show(tmp_path):
657
+ """config show works with TOML format."""
658
+ config_file = tmp_path / "config.toml"
659
+ config_file.write_text('target = "toml-show-val"\n')
660
+
661
+ app = strictcli.App(
662
+ name="testapp",
663
+ version="1.0.0",
664
+ help="test app",
665
+ config=True,
666
+ config_path=str(config_file),
667
+ config_format="toml",
668
+ )
669
+
670
+ @app.command("run", help="run something")
671
+ @strictcli.flag("target", type=str, help="the target", default="default-val")
672
+ @strictcli.flag("count", type=int, help="how many", default=1)
673
+ def run(target, count):
674
+ pass
675
+
676
+ r = app.test(["config", "show"])
677
+ assert r.exit_code == 0
678
+ assert "target = toml-show-val (source: config)" in r.stdout
679
+ assert "count = 1 (source: default)" in r.stdout
680
+
681
+
682
+ def test_toml_float_value(tmp_path):
683
+ """TOML config with float values works correctly."""
684
+ config_file = tmp_path / "config.toml"
685
+ config_file.write_text('ratio = 0.75\n')
686
+
687
+ app = strictcli.App(
688
+ name="testapp",
689
+ version="1.0.0",
690
+ help="test app",
691
+ config=True,
692
+ config_path=str(config_file),
693
+ config_format="toml",
694
+ )
695
+
696
+ @app.command("run", help="run something")
697
+ @strictcli.flag("ratio", type=float, help="ratio value", default=1.0)
698
+ def run(ratio):
699
+ print(f"ratio={ratio}")
700
+
701
+ r = app.test(["run"])
702
+ assert r.exit_code == 0
703
+ assert "ratio=0.75" in r.stdout
@@ -0,0 +1,60 @@
1
+ """Tests for App.config_file_path property."""
2
+
3
+ import os
4
+
5
+ import strictcli
6
+
7
+
8
+ def test_default_xdg_path_json(tmp_path, monkeypatch):
9
+ """Default config_file_path uses XDG_CONFIG_HOME with .json extension."""
10
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
11
+ app = strictcli.App(name="testapp", version="1.0.0", help="test app")
12
+ expected = os.path.join(str(tmp_path), "testapp", "config.json")
13
+ assert app.config_file_path == expected
14
+
15
+
16
+ def test_default_xdg_path_toml(tmp_path, monkeypatch):
17
+ """config_file_path uses .toml extension when config_format='toml'."""
18
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
19
+ app = strictcli.App(
20
+ name="testapp", version="1.0.0", help="test app", config_format="toml",
21
+ )
22
+ expected = os.path.join(str(tmp_path), "testapp", "config.toml")
23
+ assert app.config_file_path == expected
24
+
25
+
26
+ def test_custom_config_path_override(tmp_path):
27
+ """config_file_path returns the override path when config_path is set."""
28
+ custom = str(tmp_path / "custom" / "settings.json")
29
+ app = strictcli.App(
30
+ name="testapp", version="1.0.0", help="test app",
31
+ config_path=custom,
32
+ )
33
+ assert app.config_file_path == custom
34
+
35
+
36
+ def test_tilde_expansion(tmp_path, monkeypatch):
37
+ """config_file_path expands ~ in config_path."""
38
+ monkeypatch.setenv("HOME", str(tmp_path))
39
+ app = strictcli.App(
40
+ name="testapp", version="1.0.0", help="test app",
41
+ config_path="~/.myapp/config.json",
42
+ )
43
+ expected = os.path.join(str(tmp_path), ".myapp", "config.json")
44
+ assert app.config_file_path == expected
45
+
46
+
47
+ def test_matches_config_path_command(tmp_path, monkeypatch):
48
+ """config_file_path matches what 'config path' command prints."""
49
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
50
+ app = strictcli.App(
51
+ name="testapp", version="1.0.0", help="test app", config=True,
52
+ )
53
+
54
+ @app.command("run", help="run something")
55
+ def run():
56
+ pass
57
+
58
+ r = app.test(["config", "path"])
59
+ assert r.exit_code == 0
60
+ assert app.config_file_path in r.stdout
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.8.7"
235
+ version = "0.9.1"
236
236
  source = { editable = "." }
237
237
 
238
238
  [package.dev-dependencies]
@@ -1 +0,0 @@
1
- cb4155e0427b58ad57b6684d038a8ec780101d5b
@@ -1 +0,0 @@
1
- 0.41.4
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