bitranox-template-py-cli 2.3.0__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/CHANGELOG.md +13 -0
  2. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/PKG-INFO +3 -3
  3. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/docs/systemdesign/module_reference.md +2 -1
  4. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/pyproject.toml +3 -3
  5. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
  6. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/__init__.py +3 -2
  7. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/_common.py +62 -34
  8. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_email.py +5 -5
  9. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_notification.py +5 -5
  10. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_mail.py +81 -2
  11. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.devcontainer/devcontainer.json +0 -0
  12. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.devcontainer/settings.json +0 -0
  13. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.env.example +0 -0
  14. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.gitattributes +0 -0
  15. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.github/actions/extract-metadata/action.yml +0 -0
  16. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.github/dependabot.yml +0 -0
  17. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.github/workflows/codeql.yml +0 -0
  18. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.github/workflows/default_cicd_public.yml +0 -0
  19. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.github/workflows/default_release_public.yml +0 -0
  20. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.gitignore +0 -0
  21. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.qlty/qlty.toml +0 -0
  22. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/.snyk +0 -0
  23. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/CONFIG.md +0 -0
  24. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/CONTRIBUTING.md +0 -0
  25. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/DEVELOPMENT.md +0 -0
  26. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/INSTALL.md +0 -0
  27. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/LICENSE +0 -0
  28. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/Makefile +0 -0
  29. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/README.md +0 -0
  30. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/SECURITY.md +0 -0
  31. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/ai-stance.md +0 -0
  32. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/ai-transparency.md +0 -0
  33. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/codecov.yml +0 -0
  34. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/docs/adr/0001-memory-adapters-in-src.md +0 -0
  35. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/notebooks/Quickstart.ipynb +0 -0
  36. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/rename.sh +0 -0
  37. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/rename_dry.sh +0 -0
  38. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/reset_git_history.sh +0 -0
  39. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/__init__.py +0 -0
  40. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/__main__.py +0 -0
  41. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
  42. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
  43. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
  44. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/commands/config.py +0 -0
  45. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
  46. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
  47. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/config_load.py +0 -0
  48. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
  49. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
  50. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +0 -0
  51. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
  52. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
  53. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
  54. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/safe_console.py +0 -0
  55. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/cli/typed_click.py +0 -0
  56. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
  57. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/40-layered-config.toml +0 -0
  58. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +0 -0
  59. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/90-logging.toml +0 -0
  60. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
  61. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
  62. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
  63. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
  64. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/config/overrides.py +0 -0
  65. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
  66. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
  67. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/email/config.py +0 -0
  68. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
  69. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
  70. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/email/transport.py +0 -0
  71. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
  72. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
  73. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
  74. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/logging/setup.py +0 -0
  75. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/memory/__init__.py +0 -0
  76. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
  77. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/memory/email.py +0 -0
  78. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/memory/logging.py +0 -0
  79. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
  80. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/application/__init__.py +0 -0
  81. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/application/ports.py +0 -0
  82. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/application/py.typed +0 -0
  83. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/composition/__init__.py +0 -0
  84. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/composition/py.typed +0 -0
  85. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
  86. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
  87. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/domain/enums.py +0 -0
  88. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/domain/errors.py +0 -0
  89. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/domain/py.typed +0 -0
  90. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/entry.py +0 -0
  91. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/src/bitranox_template_py_cli/py.typed +0 -0
  92. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/conftest.py +0 -0
  93. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_behaviors.py +0 -0
  94. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cache_effectiveness.py +0 -0
  95. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cli_attachment_refused.py +0 -0
  96. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cli_config.py +0 -0
  97. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cli_config_errors.py +0 -0
  98. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cli_core.py +0 -0
  99. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cli_email.py +0 -0
  100. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cli_email_config_errors.py +0 -0
  101. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cli_env_file.py +0 -0
  102. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cli_exit_codes.py +0 -0
  103. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cli_main_exit.py +0 -0
  104. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cli_overrides.py +0 -0
  105. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_cli_validation.py +0 -0
  106. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_config_overrides.py +0 -0
  107. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_declared_dependencies.py +0 -0
  108. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_deploy_mode_safety.py +0 -0
  109. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_deploy_permissions.py +0 -0
  110. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_display.py +0 -0
  111. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_email_attachment_lists.py +0 -0
  112. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_email_config_translation.py +0 -0
  113. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_email_password_secrecy.py +0 -0
  114. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_email_shipped_defaults.py +0 -0
  115. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_enums.py +0 -0
  116. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_errors.py +0 -0
  117. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_logging.py +0 -0
  118. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_logging_dotenv_isolation.py +0 -0
  119. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_memory_logging.py +0 -0
  120. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_metadata.py +0 -0
  121. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_metadata_sync.py +0 -0
  122. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_module_entry.py +0 -0
  123. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_module_reference_sync.py +0 -0
  124. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_permission_defaults.py +0 -0
  125. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_ports.py +0 -0
  126. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_property_email.py +0 -0
  127. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_property_overrides.py +0 -0
  128. {bitranox_template_py_cli-2.3.0 → bitranox_template_py_cli-2.4.0}/tests/test_safe_console.py +0 -0
@@ -6,6 +6,19 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.4.0] 2026-10-08 17:34:36
10
+
11
+ ### Changed
12
+ - **The email commands' SMTP options reach the configuration as a typed `EmailConfigOverrides`
13
+ model instead of a plain dict.** `EmailConfigOverrides.from_cli_options(...)` takes the Click
14
+ values (an option that was not given is `None`, an unset `--smtp-host` an empty tuple) and
15
+ `apply_validated_overrides` merges only the options that were given. Its fields carry
16
+ EmailConfig's own names, so a field EmailConfig renames is refused instead of slipping through,
17
+ and `--smtp-password` is a `SecretStr` from the moment it is read; the model is a btx_lib_mail
18
+ `SecretSafeModel`, so a refused password never appears in a validation error's details.
19
+ `filter_sentinels` is gone; the command line behaves as before.
20
+ - Requires `pydantic>=2.14.0` (and `virtualenv>=21.14.6` in the dev extra).
21
+
9
22
  ## [2.3.0] 2026-10-08 16:14:12
10
23
 
11
24
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bitranox_template_py_cli
3
- Version: 2.3.0
3
+ Version: 2.4.0
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
@@ -27,7 +27,7 @@ Requires-Dist: lib-cli-exit-tools>=2.4.0
27
27
  Requires-Dist: lib-layered-config>=7.0.1
28
28
  Requires-Dist: lib-log-rich>=6.4.2
29
29
  Requires-Dist: orjson>=3.13.0
30
- Requires-Dist: pydantic>=2.13.5
30
+ Requires-Dist: pydantic>=2.14.0
31
31
  Requires-Dist: python-dotenv>=1.2.4
32
32
  Requires-Dist: rich-click>=1.9.9
33
33
  Provides-Extra: dev
@@ -49,7 +49,7 @@ Requires-Dist: ruff>=0.16.10; extra == 'dev'
49
49
  Requires-Dist: textual>=8.2.8; extra == 'dev'
50
50
  Requires-Dist: twine>=7.0.0; extra == 'dev'
51
51
  Requires-Dist: urllib3>=2.8.0; extra == 'dev'
52
- Requires-Dist: virtualenv>=21.14.5; extra == 'dev'
52
+ Requires-Dist: virtualenv>=21.14.6; extra == 'dev'
53
53
  Requires-Dist: wheel>=0.48.0; extra == 'dev'
54
54
  Description-Content-Type: text/markdown
55
55
 
@@ -40,7 +40,8 @@ moves or removes a module, a CLI option or a public name.
40
40
  - `commands/info.py` - info, hello, fail commands
41
41
  - `commands/config.py` - config, config-deploy, config-generate-examples commands
42
42
  - `commands/email/` - Email commands package:
43
- - `_common.py` - Shared SMTP override options, config loading and error-to-exit-code mapping
43
+ - `_common.py` - Shared SMTP override options and their typed `EmailConfigOverrides`, config
44
+ loading and error-to-exit-code mapping
44
45
  - `send_email.py` - send-email command
45
46
  - `send_notification.py` - send-notification command
46
47
  - `commands/logging.py` - logdemo command
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bitranox_template_py_cli"
3
- version = "2.3.0"
3
+ version = "2.4.0"
4
4
  description = "Template CLI application with configuration management and structured logging"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -14,7 +14,7 @@ dependencies = [
14
14
  "python-dotenv>=1.2.4",
15
15
  "lib_layered_config>=7.0.1",
16
16
  "btx_lib_mail>=4.0.0",
17
- "pydantic>=2.13.5",
17
+ "pydantic>=2.14.0",
18
18
  "orjson>=3.13.0",
19
19
  ]
20
20
  license = { text = "MIT" }
@@ -77,7 +77,7 @@ dev = [
77
77
  "wheel>=0.48.0", # CVE-2025-24049
78
78
  "pynacl>=1.6.2", # CVE-2025-69277
79
79
  "urllib3>=2.8.0", # CVE-2025-66471, CVE-2025-66418
80
- "virtualenv>=21.14.5", # CVE-2025-22702
80
+ "virtualenv>=21.14.6", # CVE-2025-22702
81
81
  ]
82
82
 
83
83
  [project.scripts]
@@ -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 = "2.3.0"
43
+ version = "2.4.0"
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.
@@ -5,12 +5,13 @@ Provides commands for sending emails and notifications via SMTP.
5
5
  Contents:
6
6
  * :func:`.send_email.cli_send_email` - Send email with optional HTML and attachments.
7
7
  * :func:`.send_notification.cli_send_notification` - Send simple plain-text notification.
8
+ * :class:`._common.EmailConfigOverrides` - The SMTP options a command line gave, typed.
8
9
  """
9
10
 
10
11
  from __future__ import annotations
11
12
 
12
- from ._common import filter_sentinels
13
+ from ._common import EmailConfigOverrides
13
14
  from .send_email import cli_send_email
14
15
  from .send_notification import cli_send_notification
15
16
 
16
- __all__ = ["cli_send_email", "cli_send_notification", "filter_sentinels"]
17
+ __all__ = ["EmailConfigOverrides", "cli_send_email", "cli_send_notification"]
@@ -9,10 +9,10 @@ from __future__ import annotations
9
9
  import functools
10
10
  import logging
11
11
  import os
12
- from typing import TYPE_CHECKING, Any, NoReturn, cast
12
+ from typing import TYPE_CHECKING, Any, NoReturn
13
13
 
14
- from btx_lib_mail import AttachmentSecurityError
15
- from pydantic import ValidationError
14
+ from btx_lib_mail import AttachmentSecurityError, SecretSafeModel
15
+ from pydantic import ConfigDict, SecretStr, ValidationError
16
16
 
17
17
  from bitranox_template_py_cli import __init__conf__
18
18
  from bitranox_template_py_cli.adapters.email.config import describe_validation_error
@@ -33,51 +33,79 @@ if TYPE_CHECKING:
33
33
  logger = logging.getLogger(__name__)
34
34
 
35
35
 
36
- def filter_sentinels(**kwargs: Any) -> dict[str, Any]:
37
- """Filter out None and empty tuple sentinels, converting tuples to lists.
36
+ class EmailConfigOverrides(SecretSafeModel):
37
+ """The EmailConfig fields the command line overrides; ``None`` means the option was not given.
38
38
 
39
- Used to prepare CLI option overrides for ``_apply_validated_overrides()``.
40
- Removes None values (unset options) and empty tuples (unset multiple options),
41
- and converts non-empty tuples to lists for Pydantic compatibility.
39
+ Each field carries EmailConfig's own name, so a field EmailConfig renames is refused here
40
+ instead of being passed along unnoticed. The password stays a ``SecretStr`` from the moment
41
+ the command line hands it over, and a validation error never carries its value, not even in
42
+ ``errors()`` or ``json()``.
43
+ """
42
44
 
43
- Args:
44
- **kwargs: Keyword arguments to filter.
45
+ model_config = ConfigDict(frozen=True, extra="forbid")
46
+ credential_fields = frozenset({"smtp_password"})
47
+
48
+ smtphosts: tuple[str, ...] | None = None
49
+ smtp_username: str | None = None
50
+ smtp_password: SecretStr | None = None
51
+ smtp_use_starttls: bool | None = None
52
+ smtp_timeout: float | None = None
53
+ raise_on_missing_attachments: bool | None = None
54
+ raise_on_invalid_recipient: bool | None = None
55
+
56
+ @classmethod
57
+ def from_cli_options(
58
+ cls,
59
+ *,
60
+ smtp_hosts: tuple[str, ...] = (),
61
+ smtp_username: str | None = None,
62
+ smtp_password: str | None = None,
63
+ use_starttls: bool | None = None,
64
+ timeout: float | None = None,
65
+ raise_on_missing_attachments: bool | None = None,
66
+ raise_on_invalid_recipient: bool | None = None,
67
+ ) -> EmailConfigOverrides:
68
+ """Build the overrides from the values Click hands a command.
69
+
70
+ Click reports an option that was not given as ``None``, and a ``multiple=True`` option
71
+ that was not given as an empty tuple; both become ``None`` here.
72
+
73
+ Examples:
74
+ >>> EmailConfigOverrides.from_cli_options(smtp_hosts=(), timeout=5.0).model_dump(exclude_none=True)
75
+ {'smtp_timeout': 5.0}
76
+ """
77
+ return cls(
78
+ smtphosts=smtp_hosts or None,
79
+ smtp_username=smtp_username,
80
+ smtp_password=None if smtp_password is None else SecretStr(smtp_password),
81
+ smtp_use_starttls=use_starttls,
82
+ smtp_timeout=timeout,
83
+ raise_on_missing_attachments=raise_on_missing_attachments,
84
+ raise_on_invalid_recipient=raise_on_invalid_recipient,
85
+ )
45
86
 
46
- Returns:
47
- Filtered dict with sentinel values removed and tuples converted to lists.
48
- """
49
- result: dict[str, Any] = {}
50
- for k, v in kwargs.items():
51
- if v is None or v == ():
52
- continue
53
- if isinstance(v, tuple):
54
- result[k] = list(cast("tuple[Any, ...]", v))
55
- else:
56
- result[k] = v
57
- return result
58
-
59
-
60
- def apply_validated_overrides(base_config: EmailConfig, overrides: dict[str, Any]) -> EmailConfig:
87
+
88
+ def apply_validated_overrides(base_config: EmailConfig, overrides: EmailConfigOverrides) -> EmailConfig:
61
89
  """Apply overrides with full Pydantic validation.
62
90
 
63
- Uses model_validate() with a merged dict instead of model_copy(update=...)
64
- to ensure Pydantic validators run on all overridden values.
91
+ Uses model_validate() with a merged mapping instead of model_copy(update=...)
92
+ to ensure EmailConfig's validators run on all overridden values.
65
93
 
66
94
  Args:
67
95
  base_config: Base EmailConfig to merge overrides into.
68
- overrides: Dict of field values to override (already filtered).
96
+ overrides: The options given on the command line.
69
97
 
70
98
  Returns:
71
- New EmailConfig with overrides applied and validated.
99
+ ``base_config`` itself when no option was given, else a new EmailConfig with the
100
+ overrides applied and validated.
72
101
 
73
102
  Raises:
74
103
  ValidationError: When overrides contain invalid values.
75
104
  """
76
- if not overrides:
105
+ given = overrides.model_dump(exclude_none=True)
106
+ if not given:
77
107
  return base_config
78
- base_dict = base_config.model_dump()
79
- merged = {**base_dict, **overrides}
80
- return EmailConfig.model_validate(merged)
108
+ return EmailConfig.model_validate({**base_config.model_dump(), **given})
81
109
 
82
110
 
83
111
  def smtp_config_options(func: Callable[..., Any]) -> Callable[..., Any]:
@@ -337,9 +365,9 @@ def _handle_send_error(
337
365
 
338
366
 
339
367
  __all__ = [
368
+ "EmailConfigOverrides",
340
369
  "apply_validated_overrides",
341
370
  "execute_with_email_error_handling",
342
- "filter_sentinels",
343
371
  "handle_validation_error",
344
372
  "load_and_validate_email_config",
345
373
  "smtp_config_options",
@@ -18,9 +18,9 @@ from ...constants import CLICK_CONTEXT_SETTINGS
18
18
  from ...context import get_cli_context
19
19
  from ...typed_click import option
20
20
  from ._common import (
21
+ EmailConfigOverrides,
21
22
  apply_validated_overrides,
22
23
  execute_with_email_error_handling,
23
- filter_sentinels,
24
24
  handle_validation_error,
25
25
  load_and_validate_email_config,
26
26
  smtp_config_options,
@@ -112,12 +112,12 @@ def cli_send_email(
112
112
 
113
113
  with lib_log_rich.runtime.bind(job_id="cli-send-email", extra=extra):
114
114
  email_config = load_and_validate_email_config(config, cli_ctx.services.load_email_config_from_dict)
115
- overrides = filter_sentinels(
116
- smtphosts=smtp_hosts,
115
+ overrides = EmailConfigOverrides.from_cli_options(
116
+ smtp_hosts=smtp_hosts,
117
117
  smtp_username=smtp_username,
118
118
  smtp_password=smtp_password,
119
- smtp_use_starttls=use_starttls,
120
- smtp_timeout=timeout,
119
+ use_starttls=use_starttls,
120
+ timeout=timeout,
121
121
  raise_on_missing_attachments=raise_on_missing_attachments,
122
122
  raise_on_invalid_recipient=raise_on_invalid_recipient,
123
123
  )
@@ -17,9 +17,9 @@ from ...constants import CLICK_CONTEXT_SETTINGS
17
17
  from ...context import get_cli_context
18
18
  from ...typed_click import option
19
19
  from ._common import (
20
+ EmailConfigOverrides,
20
21
  apply_validated_overrides,
21
22
  execute_with_email_error_handling,
22
- filter_sentinels,
23
23
  handle_validation_error,
24
24
  load_and_validate_email_config,
25
25
  smtp_config_options,
@@ -70,12 +70,12 @@ def cli_send_notification(
70
70
 
71
71
  with lib_log_rich.runtime.bind(job_id="cli-send-notification", extra=extra):
72
72
  email_config = load_and_validate_email_config(config, cli_ctx.services.load_email_config_from_dict)
73
- overrides = filter_sentinels(
74
- smtphosts=smtp_hosts,
73
+ overrides = EmailConfigOverrides.from_cli_options(
74
+ smtp_hosts=smtp_hosts,
75
75
  smtp_username=smtp_username,
76
76
  smtp_password=smtp_password,
77
- smtp_use_starttls=use_starttls,
78
- smtp_timeout=timeout,
77
+ use_starttls=use_starttls,
78
+ timeout=timeout,
79
79
  raise_on_invalid_recipient=raise_on_invalid_recipient,
80
80
  )
81
81
  try:
@@ -19,7 +19,7 @@ from btx_lib_mail import ConfMail
19
19
  from pydantic import SecretStr
20
20
  from pydantic import ValidationError as PydanticValidationError
21
21
 
22
- from bitranox_template_py_cli.adapters.cli.commands.email._common import apply_validated_overrides
22
+ from bitranox_template_py_cli.adapters.cli.commands.email._common import EmailConfigOverrides, apply_validated_overrides
23
23
  from bitranox_template_py_cli.adapters.email.sender import (
24
24
  EmailConfig,
25
25
  load_email_config_from_dict,
@@ -368,10 +368,89 @@ def test_no_rendering_of_the_email_config_shows_the_password(render: Callable[[E
368
368
  def test_an_override_keeps_the_configured_password() -> None:
369
369
  config = EmailConfig(smtphosts=["smtp.example.com:587"], smtp_username="user", smtp_password=SecretStr(_PASSWORD))
370
370
 
371
- overridden = apply_validated_overrides(config, {"smtp_timeout": 5.0})
371
+ overridden = apply_validated_overrides(config, EmailConfigOverrides.from_cli_options(timeout=5.0))
372
372
 
373
373
  assert overridden.smtp_password is not None
374
374
  assert overridden.smtp_password.get_secret_value() == _PASSWORD
375
+ assert overridden.smtp_timeout == 5.0
376
+
377
+
378
+ @pytest.mark.os_agnostic
379
+ def test_no_override_given_returns_the_configuration_itself() -> None:
380
+ config = EmailConfig(smtphosts=["smtp.example.com:587"])
381
+
382
+ assert apply_validated_overrides(config, EmailConfigOverrides.from_cli_options()) is config
383
+
384
+
385
+ @pytest.mark.os_agnostic
386
+ def test_an_unset_host_option_keeps_the_hosts_and_a_set_one_replaces_them() -> None:
387
+ config = EmailConfig(smtphosts=["smtp.example.com:587"])
388
+
389
+ unset = apply_validated_overrides(config, EmailConfigOverrides.from_cli_options(smtp_hosts=()))
390
+ replaced = apply_validated_overrides(
391
+ config, EmailConfigOverrides.from_cli_options(smtp_hosts=("other.example.com:25", "third.example.com:25"))
392
+ )
393
+
394
+ assert unset.smtphosts == ["smtp.example.com:587"]
395
+ assert replaced.smtphosts == ["other.example.com:25", "third.example.com:25"]
396
+
397
+
398
+ @pytest.mark.os_agnostic
399
+ def test_a_password_given_on_the_command_line_never_renders_from_the_overrides() -> None:
400
+ overrides = EmailConfigOverrides.from_cli_options(smtp_password=_PASSWORD)
401
+
402
+ assert _PASSWORD not in repr(overrides)
403
+ assert _PASSWORD not in str(overrides.model_dump())
404
+ assert overrides.smtp_password is not None
405
+ assert overrides.smtp_password.get_secret_value() == _PASSWORD
406
+
407
+
408
+ @pytest.mark.os_agnostic
409
+ def test_an_override_refuses_a_name_that_is_not_an_email_config_field() -> None:
410
+ """The overrides carry EmailConfig's own field names, so a renamed field cannot pass unnoticed."""
411
+ with pytest.raises(PydanticValidationError, match="smtp_hosts"):
412
+ EmailConfigOverrides.model_validate({"smtp_hosts": ["smtp.example.com:587"]})
413
+
414
+
415
+ @pytest.mark.os_agnostic
416
+ def test_each_command_line_option_lands_in_its_own_field() -> None:
417
+ """Every option is distinct from its neighbours, so a swapped parameter shows as a mismatch."""
418
+ overrides = EmailConfigOverrides.from_cli_options(
419
+ smtp_hosts=("smtp.example.com:587",),
420
+ smtp_username="user",
421
+ smtp_password=_PASSWORD,
422
+ use_starttls=False,
423
+ timeout=7.5,
424
+ raise_on_missing_attachments=True,
425
+ raise_on_invalid_recipient=False,
426
+ )
427
+
428
+ dumped = overrides.model_dump()
429
+ secret = dumped.pop("smtp_password")
430
+
431
+ assert secret.get_secret_value() == _PASSWORD
432
+ assert dumped == {
433
+ "smtphosts": ("smtp.example.com:587",),
434
+ "smtp_username": "user",
435
+ "smtp_use_starttls": False,
436
+ "smtp_timeout": 7.5,
437
+ "raise_on_missing_attachments": True,
438
+ "raise_on_invalid_recipient": False,
439
+ }
440
+
441
+
442
+ @pytest.mark.os_agnostic
443
+ def test_a_refused_password_value_stays_out_of_the_error_details() -> None:
444
+ with pytest.raises(PydanticValidationError) as caught:
445
+ EmailConfigOverrides.model_validate({"smtp_password": [_PASSWORD]})
446
+
447
+ assert _PASSWORD not in str(caught.value.errors())
448
+ assert _PASSWORD not in caught.value.json()
449
+
450
+
451
+ @pytest.mark.os_agnostic
452
+ def test_every_override_names_an_email_config_field() -> None:
453
+ assert set(EmailConfigOverrides.model_fields) <= set(EmailConfig.model_fields)
375
454
 
376
455
 
377
456
  # ======================== load_email_config_from_dict ========================