bitranox-template-py-cli 1.7.1__tar.gz → 1.7.2__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 (114) hide show
  1. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/CHANGELOG.md +9 -0
  2. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/Makefile +38 -4
  3. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/PKG-INFO +14 -14
  4. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/pyproject.toml +14 -14
  5. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
  6. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/safe_console.py +9 -4
  7. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/config/overrides.py +69 -2
  8. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_config_overrides.py +32 -0
  9. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_safe_console.py +24 -0
  10. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.devcontainer/devcontainer.json +0 -0
  11. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.devcontainer/settings.json +0 -0
  12. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.env.example +0 -0
  13. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.gitattributes +0 -0
  14. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.github/actions/extract-metadata/action.yml +0 -0
  15. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.github/dependabot.yml +0 -0
  16. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.github/workflows/codeql.yml +0 -0
  17. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.github/workflows/default_cicd_public.yml +0 -0
  18. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.github/workflows/default_release_public.yml +0 -0
  19. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.gitignore +0 -0
  20. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.qlty/qlty.toml +0 -0
  21. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/.snyk +0 -0
  22. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/CONFIG.md +0 -0
  23. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/CONTRIBUTING.md +0 -0
  24. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/DEVELOPMENT.md +0 -0
  25. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/INSTALL.md +0 -0
  26. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/LICENSE +0 -0
  27. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/README.md +0 -0
  28. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/SECURITY.md +0 -0
  29. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/ai-stance.md +0 -0
  30. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/ai-transparency.md +0 -0
  31. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/codecov.yml +0 -0
  32. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/docs/adr/0001-memory-adapters-in-src.md +0 -0
  33. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/docs/systemdesign/module_reference.md +0 -0
  34. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/notebooks/Quickstart.ipynb +0 -0
  35. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/rename.sh +0 -0
  36. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/rename_dry.sh +0 -0
  37. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/reset_git_history.sh +0 -0
  38. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/__init__.py +0 -0
  39. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/__main__.py +0 -0
  40. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
  41. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
  42. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
  43. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/commands/config.py +0 -0
  44. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/__init__.py +0 -0
  45. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/_common.py +0 -0
  46. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_email.py +0 -0
  47. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_notification.py +0 -0
  48. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
  49. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
  50. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
  51. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
  52. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +0 -0
  53. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
  54. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
  55. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
  56. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/cli/typed_click.py +0 -0
  57. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
  58. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/40-layered-config.toml +0 -0
  59. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +0 -0
  60. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/90-logging.toml +0 -0
  61. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
  62. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
  63. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
  64. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
  65. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/config/permissions.py +0 -0
  66. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
  67. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
  68. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/email/config.py +0 -0
  69. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
  70. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
  71. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/email/transport.py +0 -0
  72. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
  73. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
  74. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
  75. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/logging/setup.py +0 -0
  76. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/memory/__init__.py +0 -0
  77. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
  78. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/memory/email.py +0 -0
  79. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/memory/logging.py +0 -0
  80. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
  81. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/application/__init__.py +0 -0
  82. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/application/ports.py +0 -0
  83. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/application/py.typed +0 -0
  84. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/composition/__init__.py +0 -0
  85. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/composition/py.typed +0 -0
  86. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
  87. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
  88. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/domain/enums.py +0 -0
  89. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/domain/errors.py +0 -0
  90. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/domain/py.typed +0 -0
  91. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/entry.py +0 -0
  92. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/src/bitranox_template_py_cli/py.typed +0 -0
  93. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/conftest.py +0 -0
  94. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_behaviors.py +0 -0
  95. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_cache_effectiveness.py +0 -0
  96. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_cli_config.py +0 -0
  97. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_cli_core.py +0 -0
  98. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_cli_email.py +0 -0
  99. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_cli_env_file.py +0 -0
  100. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_cli_exit_codes.py +0 -0
  101. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_cli_overrides.py +0 -0
  102. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_cli_validation.py +0 -0
  103. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_deploy_permissions.py +0 -0
  104. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_display.py +0 -0
  105. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_enums.py +0 -0
  106. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_errors.py +0 -0
  107. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_logging.py +0 -0
  108. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_mail.py +0 -0
  109. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_metadata.py +0 -0
  110. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_metadata_sync.py +0 -0
  111. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_module_entry.py +0 -0
  112. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_ports.py +0 -0
  113. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_property_email.py +0 -0
  114. {bitranox_template_py_cli-1.7.1 → bitranox_template_py_cli-1.7.2}/tests/test_property_overrides.py +0 -0
@@ -6,6 +6,15 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.7.2] 2026-09-12 02:11:47
10
+
11
+ ### Fixed
12
+ - **`--set` overrides now report the CLI as their source.** `Config.with_overrides` returns the
13
+ merged data with the ORIGINAL provenance map by design, so an overridden key kept naming the
14
+ file it replaced and `config` sent a reader to a file holding the old value. The override path
15
+ rebuilds provenance, naming layer `cli` with no path for exactly the keys `--set` supplied and
16
+ leaving every other key's source alone.
17
+
9
18
  ## [1.7.1] 2026-08-01 01:02:45
10
19
 
11
20
  Re-release of 1.7.0, which was tagged but never published: PyPI rejected the upload because
@@ -1,4 +1,4 @@
1
- # BMK MAKEFILE 3.14.0
1
+ # BMK MAKEFILE 3.17.0
2
2
  # do not alter this file - it might be overwritten on new versions of BMK
3
3
  # if You want to alter it, remove the first line # BMK MAKEFILE 1.0 - then it is a custom makefile and will not be overwritten
4
4
  # bmk Makefile - thin wrapper using `uv tool upgrade` for persistent bmk
@@ -186,16 +186,50 @@ endif
186
186
  # BMK_MIN floors the FALLBACK install. The upgrade path does not consult it: uv re-resolves
187
187
  # the requirement recorded at install time, which is itself a `bmk>=...`, so it always lands
188
188
  # on the newest release and clears the floor anyway.
189
- BMK_MIN := 3.14.0
189
+ BMK_MIN := 3.17.0
190
190
 
191
191
  # Absent env, missing check, or damaged env - all three mean "do not upgrade, rebuild".
192
192
  # `test -x` keeps a fresh machine quiet: there is no interpreter to run yet.
193
193
  BMK_INTACT := $(if $(BMK_PY),test -x "$(BMK_PY)" && "$(BMK_PY)" -m bmk_selfcheck,false)
194
194
 
195
+ # The env this recipe mutates is shared by EVERY repo on the machine, so the mutation has to
196
+ # wait for the bmk processes running out of it. Every bmk started from that env holds a
197
+ # SHARED lock on <uv tool dir>/.bmk-tool.lock for its whole lifetime; these wrappers take
198
+ # the same lock EXCLUSIVE for the length of the upgrade. Without it, a make here deleted the
199
+ # site-packages out from under a bmk minutes into a test suite in an UNRELATED repo, and it
200
+ # surfaced as an ImportError inside bmk's OWN dependencies that cleared on a re-run - so it
201
+ # read as a flake rather than as the environment vanishing mid-gate.
202
+ #
203
+ # `bmk_toollock` is a second stdlib-only TOP-LEVEL module bmk ships, run on the tool env's
204
+ # own interpreter. That is not circular: a uv tool env's bin/python is a SYMLINK to a base
205
+ # interpreter OUTSIDE the tool dir, so the stdlib the guard runs on is not part of the tree
206
+ # uv is about to replace, and the guard is read and compiled before uv is ever spawned.
207
+ #
208
+ # NOT uv's own <uv tool dir>/.lock, which uv does honour: `uv tool upgrade` and `uv tool
209
+ # list` block on it too (measured), and the upgrade runs before every target - so a
210
+ # gate-lifetime shared lock there would make every make on the machine, and every unrelated
211
+ # `uv tool install`, wait for the longest-running gate.
212
+ #
213
+ # $(wildcard) rather than a shell test: on a machine with no tool env yet these expand to
214
+ # NOTHING and the install runs bare, which is exactly what a first install needs.
215
+ #
216
+ # The bounded wait is load-bearing in both directions. An unbounded one would serialise
217
+ # every repo's make behind the longest gate on the machine. A SKIPPED upgrade costs nothing
218
+ # (the next make picks it up), so the upgrade skips. The REPAIR may not skip: proceeding
219
+ # would leave a damaged env in place, so it fails and drops through to the retry.
220
+ #
221
+ # The LAST attempt is deliberately NEVER guarded, and a test pins that. The guard lives
222
+ # inside the env being repaired, so on the path where that env is absent or broken the guard
223
+ # may be broken too; an unguarded last resort is what stops a defective guard from making
224
+ # `make` unrunnable in every repo at once. If bmk_toollock ever falls out of the wheel, every
225
+ # make everywhere drops to a full reinstall - there is a packaging test for exactly that.
226
+ BMK_LOCK := $(if $(wildcard $(BMK_PY)),"$(BMK_PY)" -m bmk_toollock --exclusive --timeout 10 --on-timeout skip --,)
227
+ BMK_LOCK_REPAIR := $(if $(wildcard $(BMK_PY)),"$(BMK_PY)" -m bmk_toollock --exclusive --timeout 60 --on-timeout fail --,)
228
+
195
229
  .PHONY: _ensure_bmk
196
230
  _ensure_bmk:
197
- @$(BMK_INTACT) && uv tool upgrade bmk \
198
- || uv tool install --reinstall --force "bmk>=$(BMK_MIN)" \
231
+ @$(BMK_INTACT) && $(BMK_LOCK) uv tool upgrade bmk \
232
+ || $(BMK_LOCK_REPAIR) uv tool install --reinstall --force "bmk>=$(BMK_MIN)" \
199
233
  || uv tool install --reinstall --force "bmk>=$(BMK_MIN)"
200
234
 
201
235
  # ──────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: bitranox_template_py_cli
3
- Version: 1.7.1
3
+ Version: 1.7.2
4
4
  Summary: Template CLI application with configuration management and structured logging
5
5
  Project-URL: Homepage, https://github.com/bitranox/bitranox_template_py_cli
6
6
  Project-URL: Repository, https://github.com/bitranox/bitranox_template_py_cli.git
@@ -25,30 +25,30 @@ Requires-Dist: btx-lib-mail>=1.5.2
25
25
  Requires-Dist: lib-cli-exit-tools>=2.3.4
26
26
  Requires-Dist: lib-layered-config>=5.6.2
27
27
  Requires-Dist: lib-log-rich>=6.3.7
28
- Requires-Dist: orjson>=3.11.9
29
- Requires-Dist: pydantic>=2.13.4
30
- Requires-Dist: rich-click>=1.9.8
28
+ Requires-Dist: orjson>=3.12.0
29
+ Requires-Dist: pydantic>=2.13.5
30
+ Requires-Dist: rich-click>=1.9.9
31
31
  Provides-Extra: dev
32
32
  Requires-Dist: bandit>=1.9.4; extra == 'dev'
33
- Requires-Dist: build>=1.5.0; extra == 'dev'
34
- Requires-Dist: click>=8.4.2; extra == 'dev'
35
- Requires-Dist: httpx2>=2.9.1; extra == 'dev'
36
- Requires-Dist: hypothesis>=6.164.0; extra == 'dev'
37
- Requires-Dist: import-linter>=2.13; extra == 'dev'
33
+ Requires-Dist: build>=1.6.1; extra == 'dev'
34
+ Requires-Dist: click>=8.5.0; extra == 'dev'
35
+ Requires-Dist: httpx2>=2.12.0; extra == 'dev'
36
+ Requires-Dist: hypothesis>=6.168.0; extra == 'dev'
37
+ Requires-Dist: import-linter>=2.15; extra == 'dev'
38
38
  Requires-Dist: jaraco-context>=6.1.2; extra == 'dev'
39
39
  Requires-Dist: pip-audit>=2.10.1; extra == 'dev'
40
40
  Requires-Dist: pynacl>=1.6.2; extra == 'dev'
41
- Requires-Dist: pyright[nodejs]>=1.1.411; extra == 'dev'
41
+ Requires-Dist: pyright[nodejs]>=1.1.414; extra == 'dev'
42
42
  Requires-Dist: pytest-cov>=7.1.0; extra == 'dev'
43
43
  Requires-Dist: pytest>=9.1.1; extra == 'dev'
44
44
  Requires-Dist: python-multipart>=0.0.32; extra == 'dev'
45
45
  Requires-Dist: rtoml>=0.13.0; extra == 'dev'
46
- Requires-Dist: ruff>=0.16.1; extra == 'dev'
46
+ Requires-Dist: ruff>=0.16.7; extra == 'dev'
47
47
  Requires-Dist: textual>=8.2.8; extra == 'dev'
48
48
  Requires-Dist: twine>=7.0.0; extra == 'dev'
49
49
  Requires-Dist: urllib3>=2.7.0; extra == 'dev'
50
- Requires-Dist: virtualenv>=21.7.1; extra == 'dev'
51
- Requires-Dist: wheel>=0.47.0; extra == 'dev'
50
+ Requires-Dist: virtualenv>=21.7.9; extra == 'dev'
51
+ Requires-Dist: wheel>=0.48.0; extra == 'dev'
52
52
  Description-Content-Type: text/markdown
53
53
 
54
54
  # bitranox_template_py_cli
@@ -1,17 +1,17 @@
1
1
  [project]
2
2
  name = "bitranox_template_py_cli"
3
- version = "1.7.1"
3
+ version = "1.7.2"
4
4
  description = "Template CLI application with configuration management and structured logging"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
- "rich-click>=1.9.8",
8
+ "rich-click>=1.9.9",
9
9
  "lib_cli_exit_tools>=2.3.4",
10
10
  "lib_log_rich>=6.3.7",
11
11
  "lib_layered_config>=5.6.2",
12
12
  "btx_lib_mail>=1.5.2",
13
- "pydantic>=2.13.4",
14
- "orjson>=3.11.9",
13
+ "pydantic>=2.13.5",
14
+ "orjson>=3.12.0",
15
15
  ]
16
16
  license = { text = "MIT" }
17
17
  authors = [{ name = "bitranox", email = "bitranox@gmail.com" }]
@@ -48,13 +48,13 @@ Issues = "https://github.com/bitranox/bitranox_template_py_cli/issues"
48
48
  dev = [
49
49
  "pytest>=9.1.1",
50
50
  "pytest-cov>=7.1.0",
51
- "hypothesis>=6.164.0",
52
- "ruff>=0.16.1",
53
- "pyright[nodejs]>=1.1.411",
51
+ "hypothesis>=6.168.0",
52
+ "ruff>=0.16.7",
53
+ "pyright[nodejs]>=1.1.414",
54
54
  "bandit>=1.9.4",
55
55
  # 1.5.1 was yanked upstream (unintended breaking changes), so 1.5.0 is the newest usable
56
56
  # release; a >=1.5.1 floor has ZERO candidates and makes this extra unresolvable.
57
- "build>=1.5.0",
57
+ "build>=1.6.1",
58
58
  "twine>=7.0.0",
59
59
  # codecov-cli is disabled, not dropped: it requires click<8.3.0, which drags click back to
60
60
  # 8.2.1 - the version with CVE-2026-7246 (command injection in click.edit(), fixed in 8.3.3).
@@ -65,17 +65,17 @@ dev = [
65
65
  # "codecov-cli>=11.2.8",
66
66
  "pip-audit>=2.10.1",
67
67
  "textual>=8.2.8",
68
- "import-linter>=2.13",
68
+ "import-linter>=2.15",
69
69
  "rtoml>=0.13.0",
70
- "httpx2>=2.9.1",
70
+ "httpx2>=2.12.0",
71
71
  # Transitive CVE pins
72
- "click>=8.4.2", # CVE-2026-7246
72
+ "click>=8.5.0", # CVE-2026-7246
73
73
  "jaraco-context>=6.1.2", # CVE-2025-23949
74
74
  "python-multipart>=0.0.32", # CVE-2025-24486
75
- "wheel>=0.47.0", # CVE-2025-24049
75
+ "wheel>=0.48.0", # CVE-2025-24049
76
76
  "pynacl>=1.6.2", # CVE-2025-69277
77
77
  "urllib3>=2.7.0", # CVE-2025-66471, CVE-2025-66418
78
- "virtualenv>=21.7.1", # CVE-2025-22702
78
+ "virtualenv>=21.7.9", # CVE-2025-22702
79
79
  ]
80
80
 
81
81
  [project.scripts]
@@ -83,7 +83,7 @@ bitranox_template_py_cli = "bitranox_template_py_cli.entry:main"
83
83
  bitranox-template-py-cli = "bitranox_template_py_cli.entry:main"
84
84
 
85
85
  [build-system]
86
- requires = ["hatchling>=1.31.0"]
86
+ requires = ["hatchling>=1.32.0"]
87
87
  build-backend = "hatchling.build"
88
88
 
89
89
  [tool.hatch.metadata]
@@ -40,7 +40,7 @@ name = "bitranox_template_py_cli"
40
40
  #: Human-readable summary shown in CLI help output.
41
41
  title = "Template CLI application with configuration management and structured logging"
42
42
  #: Current release version pulled from ``pyproject.toml`` by automation.
43
- version = "1.7.1"
43
+ version = "1.7.2"
44
44
  #: Repository homepage presented to users.
45
45
  homepage = "https://github.com/bitranox/bitranox_template_py_cli"
46
46
  #: Author attribution surfaced in CLI output.
@@ -64,13 +64,19 @@ ASCII_FALLBACKS: Final[dict[str, str]] = {
64
64
  _UNIVERSAL_ENCODINGS: Final[frozenset[str]] = frozenset({"utf-8", "utf8", "utf-16", "utf16", "utf-32", "utf32"})
65
65
 
66
66
 
67
- def _stream_encoding(file: IO[Any] | None) -> str | None:
67
+ def _stream_encoding(file: IO[Any] | None, *, err: bool = False) -> str | None:
68
68
  """Return the target stream's encoding, or None when it cannot be determined.
69
69
 
70
70
  An unknown encoding means the caller gets the original text: guessing would
71
71
  degrade output that may well have been fine.
72
+
73
+ With no explicit `file` the answer comes from ``sys.stdout``/``sys.stderr``,
74
+ which is what :func:`click.echo` resolves its own default target from. click
75
+ exposes no supported way to ask for that stream: ``get_text_stream`` was
76
+ deprecated in click 8.5.0 and is removed in 9.0, its documented replacement
77
+ being to let ``echo`` resolve the stream itself.
72
78
  """
73
- stream = file if file is not None else click.get_text_stream("stdout")
79
+ stream = file if file is not None else (sys.stderr if err else sys.stdout)
74
80
  encoding = getattr(stream, "encoding", None)
75
81
  return encoding if isinstance(encoding, str) else None
76
82
 
@@ -135,8 +141,7 @@ def echo(message: object = "", *, file: IO[Any] | None = None, err: bool = False
135
141
  Writes to the given stream.
136
142
  """
137
143
  text = message if isinstance(message, str) else str(message)
138
- target = file if file is not None else click.get_text_stream("stderr" if err else "stdout")
139
- click.echo(encode_safe(text, _stream_encoding(target)), file=target, nl=nl)
144
+ click.echo(encode_safe(text, _stream_encoding(file, err=err)), file=file, err=err, nl=nl)
140
145
 
141
146
 
142
147
  class _SafeWriter:
@@ -2,13 +2,22 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Iterator, Mapping
5
6
  from dataclasses import dataclass
6
7
  from typing import TYPE_CHECKING, cast
7
8
 
8
9
  import orjson
10
+ from lib_layered_config import Config
9
11
 
10
12
  if TYPE_CHECKING:
11
- from lib_layered_config import Config
13
+ from lib_layered_config.domain.config import SourceInfo
14
+
15
+ CLI_LAYER = "cli"
16
+ """Provenance layer recorded for a value that came from ``--set``.
17
+
18
+ It is not one of the library's file layers because it names no file: the
19
+ value was typed on the command line and there is nothing to open.
20
+ """
12
21
 
13
22
  CoercedValue = str | int | float | bool | None | list[object] | dict[str, object]
14
23
  """Union of types that :func:`coerce_value` can produce."""
@@ -145,6 +154,53 @@ def _nest_override(target: dict[str, dict[str, object]], override: ConfigOverrid
145
154
  node[override.key_path[-1]] = override.value
146
155
 
147
156
 
157
+ def _dotted_keys(data: Mapping[str, object], prefix: str = "") -> Iterator[str]:
158
+ """Yield every leaf key of a nested mapping in dotted form.
159
+
160
+ Args:
161
+ data: The nested mapping to walk.
162
+ prefix: Dotted path accumulated by the caller, ending in a dot.
163
+
164
+ Yields:
165
+ One dotted path per leaf value.
166
+
167
+ Example:
168
+ >>> sorted(_dotted_keys({"a": {"b": 1, "c": {"d": 2}}}))
169
+ ['a.b', 'a.c.d']
170
+ """
171
+ for key, value in data.items():
172
+ path = f"{prefix}{key}"
173
+ if isinstance(value, Mapping):
174
+ yield from _dotted_keys(cast("Mapping[str, object]", value), f"{path}.")
175
+ else:
176
+ yield path
177
+
178
+
179
+ def _provenance_naming_the_cli(
180
+ config: Config, merged_as_dict: Mapping[str, object], overridden: frozenset[str]
181
+ ) -> dict[str, SourceInfo]:
182
+ """Copy a Config's provenance, relabelling the keys an override replaced.
183
+
184
+ Args:
185
+ config: The Config the values came from, holding the original map.
186
+ merged_as_dict: The merged Config's own mapping, walked for the full
187
+ key set. Passed in already computed, because the caller needs the
188
+ same mapping to build the merged ``Config``.
189
+ overridden: Dotted keys that ``--set`` supplied.
190
+
191
+ Returns:
192
+ A provenance map naming the CLI for overridden keys and the original
193
+ source for every other one.
194
+ """
195
+ provenance: dict[str, SourceInfo] = {}
196
+ for dotted in _dotted_keys(merged_as_dict):
197
+ if dotted in overridden:
198
+ provenance[dotted] = {"layer": CLI_LAYER, "path": None, "key": dotted}
199
+ elif (origin := config.origin(dotted)) is not None:
200
+ provenance[dotted] = origin
201
+ return provenance
202
+
203
+
148
204
  def apply_overrides(config: Config, raw_overrides: tuple[str, ...]) -> Config:
149
205
  """Deep-merge CLI overrides into a Config instance.
150
206
 
@@ -168,6 +224,8 @@ def apply_overrides(config: Config, raw_overrides: tuple[str, ...]) -> Config:
168
224
  >>> result = apply_overrides(cfg, ("s.k=2",))
169
225
  >>> result["s"]["k"]
170
226
  2
227
+ >>> result.origin("s.k")["layer"]
228
+ 'cli'
171
229
  >>> apply_overrides(cfg, ()) is cfg
172
230
  True
173
231
  """
@@ -175,14 +233,23 @@ def apply_overrides(config: Config, raw_overrides: tuple[str, ...]) -> Config:
175
233
  return config
176
234
 
177
235
  overrides: dict[str, dict[str, object]] = {}
236
+ overridden: set[str] = set()
178
237
  for raw in raw_overrides:
179
238
  parsed = parse_override(raw)
180
239
  _nest_override(overrides, parsed)
240
+ overridden.add(".".join((parsed.section, *parsed.key_path)))
181
241
 
182
- return config.with_overrides(overrides)
242
+ # Rebuilt rather than returned straight from with_overrides, which shares
243
+ # the original provenance map by design: the merged value is the CLI's and
244
+ # its recorded origin still named the file it replaced, so `config` sent a
245
+ # reader to a file holding the old value.
246
+ merged = config.with_overrides(overrides)
247
+ merged_as_dict = merged.as_dict()
248
+ return Config(merged_as_dict, _provenance_naming_the_cli(config, merged_as_dict, frozenset(overridden)))
183
249
 
184
250
 
185
251
  __all__ = [
252
+ "CLI_LAYER",
186
253
  "CoercedValue",
187
254
  "ConfigOverride",
188
255
  "apply_overrides",
@@ -325,3 +325,35 @@ def test_apply_overrides_rejects_malformed_input() -> None:
325
325
 
326
326
  with pytest.raises(ValueError, match="must contain '='"):
327
327
  apply_overrides(config, ("invalid",))
328
+
329
+
330
+ @pytest.mark.os_agnostic
331
+ def test_apply_overrides_names_the_cli_as_the_source_of_an_overridden_key() -> None:
332
+ """An overridden key reports the CLI as its origin, not the layer it replaced.
333
+
334
+ ``Config.with_overrides`` returns the merged data with the ORIGINAL provenance
335
+ map by design, so without a rebuild ``config`` answers "where did this come
336
+ from" with the file holding the value that was just replaced.
337
+ """
338
+ config = Config({"s": {"k": 1}}, {"s.k": {"layer": "file", "path": "/etc/app.toml", "key": "s.k"}})
339
+
340
+ result = apply_overrides(config, ("s.k=2",))
341
+
342
+ assert result["s"]["k"] == 2
343
+ assert result.origin("s.k") == {"layer": "cli", "path": None, "key": "s.k"}
344
+
345
+
346
+ @pytest.mark.os_agnostic
347
+ def test_apply_overrides_leaves_an_untouched_key_with_its_own_source() -> None:
348
+ """Only the keys ``--set`` supplied are relabelled; the rest keep their layer."""
349
+ config = Config(
350
+ {"s": {"k": 1, "other": "kept"}},
351
+ {
352
+ "s.k": {"layer": "file", "path": "/etc/app.toml", "key": "s.k"},
353
+ "s.other": {"layer": "env", "path": None, "key": "s.other"},
354
+ },
355
+ )
356
+
357
+ result = apply_overrides(config, ("s.k=2",))
358
+
359
+ assert result.origin("s.other") == {"layer": "env", "path": None, "key": "s.other"}
@@ -81,6 +81,30 @@ class TestEchoOnAUtf8Console:
81
81
  assert "✓" in buffer.getvalue().decode("utf-8")
82
82
 
83
83
 
84
+ class TestTheDefaultTargetFollowsTheStreamEchoWritesTo:
85
+ """With no `file`, the encoding must come from the stream `echo` lands on.
86
+
87
+ click resolves that target itself from ``sys.stdout``/``sys.stderr``. Judge
88
+ the wrong stream and a legacy-codepage console gets exactly the crash this
89
+ module exists to prevent, while every test passing an explicit `file` stays
90
+ green and says nothing about it.
91
+ """
92
+
93
+ def test_a_cp1252_stdout_degrades_the_glyph(self, monkeypatch: pytest.MonkeyPatch) -> None:
94
+ stream = _cp1252_stream()
95
+ monkeypatch.setattr(sys, "stdout", stream)
96
+ safe_console.echo("✓ deployed")
97
+ assert "[OK]" in _read_back(stream)
98
+
99
+ def test_err_is_judged_against_stderr_not_stdout(self, monkeypatch: pytest.MonkeyPatch) -> None:
100
+ """A stdout that could take the glyph must not excuse a cp1252 stderr."""
101
+ stream = _cp1252_stream()
102
+ monkeypatch.setattr(sys, "stdout", io.TextIOWrapper(io.BytesIO(), encoding="utf-8", newline=""))
103
+ monkeypatch.setattr(sys, "stderr", stream)
104
+ safe_console.echo("✓ deployed", err=True)
105
+ assert "[OK]" in _read_back(stream)
106
+
107
+
84
108
  class TestSafeStreamProtectsRich:
85
109
  """Rich raises on a legacy codepage too; it renders through its own writer."""
86
110