bitranox-template-py-cli 2.1.0__tar.gz → 2.1.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 (127) hide show
  1. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/CHANGELOG.md +21 -0
  2. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/PKG-INFO +1 -1
  3. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/docs/systemdesign/module_reference.md +7 -6
  4. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/pyproject.toml +1 -1
  5. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
  6. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/_common.py +2 -2
  7. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/config_load.py +9 -5
  8. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/transport.py +5 -3
  9. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/setup.py +62 -14
  10. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/logging.py +16 -11
  11. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/ports.py +2 -0
  12. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_email_config_translation.py +20 -3
  13. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_logging_dotenv_isolation.py +129 -2
  14. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_mail.py +67 -0
  15. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_memory_logging.py +25 -0
  16. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.devcontainer/devcontainer.json +0 -0
  17. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.devcontainer/settings.json +0 -0
  18. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.env.example +0 -0
  19. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.gitattributes +0 -0
  20. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.github/actions/extract-metadata/action.yml +0 -0
  21. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.github/dependabot.yml +0 -0
  22. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.github/workflows/codeql.yml +0 -0
  23. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.github/workflows/default_cicd_public.yml +0 -0
  24. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.github/workflows/default_release_public.yml +0 -0
  25. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.gitignore +0 -0
  26. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.qlty/qlty.toml +0 -0
  27. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.snyk +0 -0
  28. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/CONFIG.md +0 -0
  29. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/CONTRIBUTING.md +0 -0
  30. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/DEVELOPMENT.md +0 -0
  31. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/INSTALL.md +0 -0
  32. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/LICENSE +0 -0
  33. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/Makefile +0 -0
  34. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/README.md +0 -0
  35. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/SECURITY.md +0 -0
  36. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/ai-stance.md +0 -0
  37. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/ai-transparency.md +0 -0
  38. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/codecov.yml +0 -0
  39. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/docs/adr/0001-memory-adapters-in-src.md +0 -0
  40. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/notebooks/Quickstart.ipynb +0 -0
  41. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/rename.sh +0 -0
  42. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/rename_dry.sh +0 -0
  43. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/reset_git_history.sh +0 -0
  44. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__init__.py +0 -0
  45. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__main__.py +0 -0
  46. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
  47. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
  48. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
  49. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/config.py +0 -0
  50. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/__init__.py +0 -0
  51. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_email.py +0 -0
  52. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_notification.py +0 -0
  53. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
  54. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
  55. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
  56. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
  57. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +0 -0
  58. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
  59. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
  60. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
  61. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/safe_console.py +0 -0
  62. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/typed_click.py +0 -0
  63. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
  64. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/40-layered-config.toml +0 -0
  65. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +0 -0
  66. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/90-logging.toml +0 -0
  67. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
  68. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
  69. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
  70. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
  71. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/overrides.py +0 -0
  72. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
  73. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
  74. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/config.py +0 -0
  75. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
  76. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
  77. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
  78. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
  79. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
  80. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/__init__.py +0 -0
  81. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
  82. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/email.py +0 -0
  83. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
  84. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/__init__.py +0 -0
  85. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/py.typed +0 -0
  86. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/composition/__init__.py +0 -0
  87. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/composition/py.typed +0 -0
  88. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
  89. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
  90. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/enums.py +0 -0
  91. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/errors.py +0 -0
  92. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/py.typed +0 -0
  93. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/entry.py +0 -0
  94. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/py.typed +0 -0
  95. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/conftest.py +0 -0
  96. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_behaviors.py +0 -0
  97. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cache_effectiveness.py +0 -0
  98. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_config.py +0 -0
  99. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_config_errors.py +0 -0
  100. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_core.py +0 -0
  101. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_email.py +0 -0
  102. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_email_config_errors.py +0 -0
  103. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_env_file.py +0 -0
  104. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_exit_codes.py +0 -0
  105. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_main_exit.py +0 -0
  106. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_overrides.py +0 -0
  107. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_validation.py +0 -0
  108. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_config_overrides.py +0 -0
  109. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_declared_dependencies.py +0 -0
  110. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_deploy_mode_safety.py +0 -0
  111. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_deploy_permissions.py +0 -0
  112. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_display.py +0 -0
  113. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_email_attachment_lists.py +0 -0
  114. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_email_password_secrecy.py +0 -0
  115. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_email_shipped_defaults.py +0 -0
  116. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_enums.py +0 -0
  117. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_errors.py +0 -0
  118. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_logging.py +0 -0
  119. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_metadata.py +0 -0
  120. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_metadata_sync.py +0 -0
  121. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_module_entry.py +0 -0
  122. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_module_reference_sync.py +0 -0
  123. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_permission_defaults.py +0 -0
  124. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_ports.py +0 -0
  125. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_property_email.py +0 -0
  126. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_property_overrides.py +0 -0
  127. {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_safe_console.py +0 -0
@@ -6,6 +6,27 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.1.1] 2026-10-06 18:13:05
10
+
11
+ ### Fixed
12
+ - **A refused `LOG_*` variable no longer disables every command (exit code change).** A value
13
+ lib_log_rich refuses in a `LOG_*` variable, set in the environment or in the `.env` logging
14
+ reads (`LOG_CONSOLE_LEVEL=bogus` in the `--env-file`), made every command, `info` and
15
+ `config-deploy` included, exit 1 with `InvalidLoggingConfigError: lib_log_rich: Unknown log
16
+ level: 'bogus'`: the fallback restarted logging with an empty configuration, but lib_log_rich
17
+ reads the `LOG_*` variables on every start and refused the same variable again. Logging now
18
+ falls back to its defaults with every `LOG_*` variable hidden for that start (and put back
19
+ afterwards), so only the commands that read the configuration (`config`, `send-email`,
20
+ `send-notification`) exit 78, and the others run with exit 0. The 78 carries lib_log_rich's
21
+ own message, `Error: lib_log_rich: Unknown log level: 'bogus'`, which may name neither the
22
+ variable nor where it was set. A refused `[lib_log_rich]` value still leaves every valid
23
+ `LOG_*` variable in force for the fallback, as in 2.1.0: only a refused variable hides them.
24
+ - **The testing composition ignores the developer's `LOG_*` variables.** `build_testing()`'s
25
+ logging runtime (`init_logging_in_memory`) now starts with every `LOG_*` variable hidden and
26
+ puts them back afterwards. A `LOG_CONSOLE_LEVEL=bogus` in the shell running the tests made
27
+ every command under `build_testing()` fail with `ValueError: Unknown log level: 'bogus'`, and a
28
+ valid one changed the quiet test runtime. Production logging is unaffected.
29
+
9
30
  ## [2.1.0] 2026-10-06 15:37:27
10
31
 
11
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bitranox_template_py_cli
3
- Version: 2.1.0
3
+ Version: 2.1.1
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
@@ -26,7 +26,7 @@ moves or removes a module, a CLI option or a public name.
26
26
  - `src/bitranox_template_py_cli/adapters/email/transport.py` - `send_email` / `send_notification` over btx_lib_mail
27
27
  - `src/bitranox_template_py_cli/adapters/email/sender.py` - Re-exports of `config.py` and `transport.py`
28
28
  - `src/bitranox_template_py_cli/adapters/email/validation.py` - Email recipient validation
29
- - `src/bitranox_template_py_cli/adapters/logging/setup.py` - lib_log_rich initialization
29
+ - `src/bitranox_template_py_cli/adapters/logging/setup.py` - lib_log_rich initialization; takes only the `LOG_*` lines of a `.env`, and raises `InvalidLoggingConfigError` for a refused `[lib_log_rich]` value or `LOG_*` variable, after starting logging with its defaults (and without the `LOG_*` variables only when one of them is refused)
30
30
  - `src/bitranox_template_py_cli/adapters/cli/` - CLI adapter package:
31
31
  - `__init__.py` - Public facade
32
32
  - `constants.py` - Shared constants
@@ -92,6 +92,7 @@ moves or removes a module, a CLI option or a public name.
92
92
  - `tests/test_enums.py` - Domain enum tests
93
93
  - `tests/test_errors.py` - Domain error types
94
94
  - `tests/test_logging.py` - Logging configuration model
95
+ - `tests/test_logging_dotenv_isolation.py` - Only `LOG_*` lines of a `.env` reach the environment; an invalid `[lib_log_rich]` section or `LOG_*` variable is a configuration failure that leaves logging running
95
96
  - `tests/test_mail.py` - Email configuration and sending tests (the `integration` ones send real mail)
96
97
  - `tests/test_memory_logging.py` - Testing-composition logging runtime and the per-test logging reset
97
98
  - `tests/test_metadata.py` - Package metadata and PEP 561 marker tests
@@ -322,9 +323,9 @@ Raises `ValueError` with descriptive message on invalid input.
322
323
  `EmailConfig` (`adapters/email/config.py`, re-exported by `adapters/email/sender.py`) subclasses
323
324
  btx_lib_mail's `ConfMail`: frozen, a name that is not a field is refused, the password is a
324
325
  `SecretStr`, and a validation error never shows the password or a host. It adds `from_address` and
325
- `recipients`, and checks every SMTP host's syntax (port range, IPv6 brackets) when it loads. In
326
- Python the fields use the library's names; the configuration file keeps its own keys (third
327
- column).
326
+ `recipients`. ConfMail checks every SMTP host's syntax (port range, IPv6 brackets, host name
327
+ labels) when it loads, so `EmailConfig` has no host check of its own. In Python the fields use
328
+ the library's names; the configuration file keeps its own keys (fourth column).
328
329
 
329
330
  | Field | Type | Default | File key (`[email]`) | Description |
330
331
  |--------------------------------|---------------------|---------|--------------------------------|------------------------------------------------------------------|
@@ -362,7 +363,7 @@ lib_layered_config is the only reader of configuration: it merges every layer (t
362
363
  defaults, the app, host and user files, `.env`, the environment, `--set`) into one mapping.
363
364
  `load_email_config_from_dict()` turns that mapping's `[email]` section into an `EmailConfig`:
364
365
 
365
- - the six keys in the third column that differ from the field names are mapped;
366
+ - the six keys in the fourth column that differ from the field names are mapped;
366
367
  - `[email.attachments]` keys become `attachment_<key>`;
367
368
  - a key that is not listed is refused (exit 78, `email.<key>: unknown key`), whatever layer it came from;
368
369
  - blank text means "not configured"; a single host or address string is a one-entry list;
@@ -379,7 +380,7 @@ problem, never with the refused value.
379
380
  |---------------------|------------------------------------------------------------------------------|
380
381
  | `SECTION_KEYS` | Every key `[email]` accepts (the `attachments` table included) |
381
382
  | `ATTACHMENT_KEYS` | Every key `[email.attachments]` accepts; each names field `attachment_<key>` |
382
- | `FILE_KEY_TO_FIELD` | File key -> `EmailConfig` field, for the five keys whose names differ |
383
+ | `FILE_KEY_TO_FIELD` | File key -> `EmailConfig` field, for the six keys whose names differ |
383
384
 
384
385
  ---
385
386
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bitranox_template_py_cli"
3
- version = "2.1.0"
3
+ version = "2.1.1"
4
4
  description = "Template CLI application with configuration management and structured logging"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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.1.0"
43
+ version = "2.1.1"
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.
@@ -84,8 +84,8 @@ def smtp_config_options(func: Callable[..., Any]) -> Callable[..., Any]:
84
84
 
85
85
  Adds CLI flags for the SMTP connection and delivery settings (hosts, credentials,
86
86
  STARTTLS, timeout, the two raise_on_* switches). ``starttls_verify``,
87
- ``local_hostname`` and the attachment settings have no flag; set them with
88
- ``--set email.<key>=...``.
87
+ ``local_hostname``, ``delivery_deadline``, ``recipient_max_count`` and the attachment
88
+ settings have no flag; set them with ``--set email.<key>=...``.
89
89
  """
90
90
  options = [
91
91
  option(
@@ -14,7 +14,8 @@ configuration.
14
14
 
15
15
  Contents:
16
16
  * :func:`load_config` - load with profile, ``.env`` and ``--set``, or say why not.
17
- * :func:`start_logging` - start logging; an invalid ``[lib_log_rich]`` is a load failure.
17
+ * :func:`start_logging` - start logging; an invalid ``[lib_log_rich]`` value or ``LOG_*``
18
+ variable is a load failure.
18
19
  * :func:`require_config` - the configuration, or exit 78 naming the failure.
19
20
  * :func:`report_load_failure` - the one-line report, after the traceback on request.
20
21
  * :func:`echo_load_traceback` - the loader's traceback alone, for a caller with its own line.
@@ -97,9 +98,13 @@ def start_logging(
97
98
  ) -> tuple[Config, Exception | None]:
98
99
  """Start logging with ``config``; a logging section it refuses is recorded like a load failure.
99
100
 
100
- An invalid ``[lib_log_rich]`` value would otherwise stop every command, ``config-deploy``
101
- (which replaces the file holding it) included. Logging then starts with its defaults, and
102
- the commands that read the configuration refuse with exit 78 naming the key.
101
+ An invalid ``[lib_log_rich]`` value or ``LOG_*`` variable would otherwise stop every
102
+ command, ``config-deploy`` (which replaces the file holding it) included. ``init_logging``
103
+ then starts logging with its defaults (and no ``LOG_*`` variable, if one of them is the
104
+ refused setting) before it raises, and the commands that read the configuration refuse with
105
+ exit 78 and the refusal: ``lib_log_rich.<key>: <reason>`` for a problem the section's type
106
+ check finds, lib_log_rich's own message for a value only lib_log_rich refuses, whether it
107
+ came from the section or a ``LOG_*`` variable (``lib_log_rich: Unknown log level: 'bogus'``).
103
108
 
104
109
  Args:
105
110
  services: The composition's services; only ``init_logging`` is used.
@@ -114,7 +119,6 @@ def start_logging(
114
119
  try:
115
120
  services.init_logging(config, dotenv_path=env_file)
116
121
  except InvalidLoggingConfigError as exc:
117
- services.init_logging(Config({}, {}), dotenv_path=env_file)
118
122
  return Config({}, {}), config_error or exc
119
123
  return config, config_error
120
124
 
@@ -122,10 +122,12 @@ def send_email(
122
122
 
123
123
  Raises:
124
124
  ValueError: No from_address configured and no override provided,
125
- or no recipients configured and no override provided.
125
+ or no recipients configured and no override provided, or more
126
+ recipients or attachments than config.recipient_max_count or
127
+ config.attachment_max_count allow (refused before any delivery).
126
128
  ConfigurationError: No SMTP hosts configured.
127
- FileNotFoundError: Required attachment missing and config.raise_on_missing_attachments
128
- is True.
129
+ FileNotFoundError: Required attachment missing or unreadable and
130
+ config.raise_on_missing_attachments is True.
129
131
  DeliveryError: All SMTP hosts failed for a recipient.
130
132
 
131
133
  Side Effects:
@@ -5,8 +5,10 @@ eliminating duplication between module entry (__main__.py) and console script
5
5
  (cli.py) while ensuring initialization happens exactly once.
6
6
 
7
7
  Contents:
8
- * :func:`init_logging` - idempotent logging initialization with layered config.
9
- * :class:`InvalidLoggingConfigError` - the ``[lib_log_rich]`` section cannot configure logging.
8
+ * :func:`init_logging` - idempotent logging initialization from the ``[lib_log_rich]``
9
+ section and the ``LOG_*`` variables (the environment, plus the ``LOG_*`` lines of a ``.env``).
10
+ * :class:`InvalidLoggingConfigError` - the ``[lib_log_rich]`` section or a ``LOG_*`` variable
11
+ cannot configure logging.
10
12
  * :func:`_build_runtime_config` - constructs RuntimeConfig from layered sources.
11
13
 
12
14
  System Role:
@@ -18,18 +20,20 @@ System Role:
18
20
  from __future__ import annotations
19
21
 
20
22
  import os
23
+ from contextlib import contextmanager
21
24
  from pathlib import Path
22
25
  from typing import TYPE_CHECKING, cast
23
26
 
24
27
  import lib_log_rich.runtime
25
28
  from dotenv import dotenv_values
29
+ from lib_layered_config import Config
26
30
  from pydantic import BaseModel, ConfigDict, ValidationError
27
31
 
28
32
  from bitranox_template_py_cli import __init__conf__
29
33
  from bitranox_template_py_cli.domain.errors import ConfigurationError
30
34
 
31
35
  if TYPE_CHECKING:
32
- from lib_layered_config import Config
36
+ from collections.abc import Generator
33
37
 
34
38
 
35
39
  class LoggingConfigModel(BaseModel):
@@ -94,18 +98,20 @@ def _build_runtime_config(config: Config) -> lib_log_rich.runtime.RuntimeConfig:
94
98
  class InvalidLoggingConfigError(ConfigurationError):
95
99
  """lib_log_rich refuses its settings: one ``<key>: <reason>`` in :attr:`problems` per problem.
96
100
 
97
- The settings are the ``[lib_log_rich]`` section plus any ``LOG_*`` variable. A problem names
98
- the key and never repeats the refused value.
101
+ The settings are the ``[lib_log_rich]`` section plus any ``LOG_*`` variable. A problem the
102
+ type check of the section finds names the key and never repeats the refused value. A value
103
+ only lib_log_rich itself refuses, such as an unknown level in ``LOG_CONSOLE_LEVEL`` or in the
104
+ section's ``console_level``, is reported in lib_log_rich's own words (``lib_log_rich: Unknown
105
+ log level: 'bogus'``), which may name neither the setting nor where it was set.
99
106
 
100
107
  Attributes:
101
108
  problems: One line per refused setting.
102
109
 
103
110
  Example:
104
- >>> from lib_layered_config import Config
105
111
  >>> try:
106
- ... init_logging(Config({"lib_log_rich": {"rate_limit": "100:60"}}, {}))
107
- ... except InvalidLoggingConfigError as exc:
108
- ... print(exc)
112
+ ... _build_runtime_config(Config({"lib_log_rich": {"rate_limit": "100:60"}}, {}))
113
+ ... except ValidationError as exc:
114
+ ... print(InvalidLoggingConfigError(_problems(exc)))
109
115
  lib_log_rich.rate_limit: Input should be a valid tuple
110
116
  """
111
117
 
@@ -126,8 +132,8 @@ def _problems(error: BaseException) -> list[str]:
126
132
  while cause is not None and not isinstance(cause, ValidationError):
127
133
  cause = cause.__cause__ or cause.__context__
128
134
  if cause is None:
129
- # Not a pydantic error (e.g. an unknown level name): its own first line, which names
130
- # the setting.
135
+ # Not a pydantic error (e.g. an unknown level name in a LOG_* variable): lib_log_rich's
136
+ # own first line, which may name neither the variable nor where it was set.
131
137
  return [f"lib_log_rich: {str(error).splitlines()[0]}"]
132
138
  return [f"lib_log_rich.{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" for item in cause.errors()]
133
139
 
@@ -182,6 +188,41 @@ def _load_log_variables(dotenv_path: str | None) -> None:
182
188
  os.environ.setdefault(name, value)
183
189
 
184
190
 
191
+ @contextmanager
192
+ def log_variables_hidden() -> Generator[None]:
193
+ """Remove every ``LOG_*`` variable from the environment for the block, then put each back.
194
+
195
+ Example:
196
+ >>> os.environ["LOG_HIDDEN_PROBE"] = "x"
197
+ >>> with log_variables_hidden():
198
+ ... "LOG_HIDDEN_PROBE" in os.environ
199
+ False
200
+ >>> os.environ.pop("LOG_HIDDEN_PROBE")
201
+ 'x'
202
+ """
203
+ names = [name for name in os.environ if name.startswith(_LOG_VARIABLE_PREFIX)]
204
+ hidden = {name: os.environ.pop(name) for name in names}
205
+ try:
206
+ yield
207
+ finally:
208
+ os.environ.update(hidden)
209
+
210
+
211
+ def _start_default_logging() -> None:
212
+ """Start lib_log_rich with the package defaults; without the ``LOG_*`` variables only if needed.
213
+
214
+ lib_log_rich reads the ``LOG_*`` variables from the environment on every init. When the
215
+ refused setting came from the ``[lib_log_rich]`` section, the defaults start with them and
216
+ a valid ``LOG_CONSOLE_LEVEL`` still applies. When one of them is the refused setting, that
217
+ start is refused again, and only then do the defaults start with every ``LOG_*`` hidden.
218
+ """
219
+ try:
220
+ lib_log_rich.runtime.init(_build_runtime_config(Config({}, {})))
221
+ except ValueError: # pydantic's ValidationError is a ValueError
222
+ with log_variables_hidden():
223
+ lib_log_rich.runtime.init(_build_runtime_config(Config({}, {})))
224
+
225
+
185
226
  def init_logging(config: Config, *, dotenv_path: str | None = None) -> None:
186
227
  """Initialize lib_log_rich runtime with the provided configuration.
187
228
 
@@ -199,8 +240,12 @@ def init_logging(config: Config, *, dotenv_path: str | None = None) -> None:
199
240
  to use the nearest ``.env`` from the working directory up to the project root.
200
241
 
201
242
  Raises:
202
- InvalidLoggingConfigError: The ``[lib_log_rich]`` section holds a value lib_log_rich
203
- refuses; logging is not started.
243
+ InvalidLoggingConfigError: The ``[lib_log_rich]`` section or a ``LOG_*`` variable holds
244
+ a value lib_log_rich refuses. Logging is started anyway, with the package defaults
245
+ and the ``LOG_*`` variables (every one of them ignored if the defaults are refused
246
+ with them too), so the caller can record the failure and run the commands that do
247
+ not read the configuration. Only the first call can raise: logging is running after
248
+ it, so a later call returns at once, also with the same refused configuration.
204
249
 
205
250
  Side Effects:
206
251
  Copies the ``LOG_*`` lines of that ``.env`` into the process environment on first
@@ -226,9 +271,11 @@ def init_logging(config: Config, *, dotenv_path: str | None = None) -> None:
226
271
  _load_log_variables(dotenv_path)
227
272
  try:
228
273
  lib_log_rich.runtime.init(_build_runtime_config(config))
229
- except (ValidationError, ValueError) as exc:
274
+ except ValueError as exc: # pydantic's ValidationError is a ValueError
230
275
  # The type check of the section and lib_log_rich's own range checks (which also see the
231
276
  # LOG_* variables) both refuse here; neither has started the runtime.
277
+ _start_default_logging()
278
+ lib_log_rich.runtime.attach_std_logging()
232
279
  raise InvalidLoggingConfigError(_problems(exc)) from exc
233
280
  lib_log_rich.runtime.attach_std_logging()
234
281
 
@@ -237,4 +284,5 @@ __all__ = [
237
284
  "InvalidLoggingConfigError",
238
285
  "LoggingConfigModel",
239
286
  "init_logging",
287
+ "log_variables_hidden",
240
288
  ]
@@ -12,6 +12,7 @@ import lib_log_rich.runtime
12
12
  from lib_log_rich.domain import LogLevel
13
13
 
14
14
  from bitranox_template_py_cli import __init__conf__
15
+ from bitranox_template_py_cli.adapters.logging.setup import log_variables_hidden
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from lib_layered_config import Config
@@ -29,7 +30,10 @@ def init_logging_in_memory(config: Config, *, dotenv_path: str | None = None) ->
29
30
  double has no business writing to a system log, and a queue is a background thread the
30
31
  test run would have to reap. The console level is ERROR so ordinary INFO logging cannot
31
32
  land in the output a test asserts on. Unlike the production initializer this never loads
32
- a ``.env`` file, so a test run does not pick up the developer's own environment.
33
+ a ``.env`` file, and it starts with every ``LOG_*`` variable hidden (each is put back
34
+ afterwards): lib_log_rich reads them on every init, so a developer's own
35
+ ``LOG_CONSOLE_LEVEL`` would otherwise change the test runtime, and a value lib_log_rich
36
+ refuses would fail every command run under ``build_testing()``.
33
37
 
34
38
  Args:
35
39
  config: Layered configuration object. Unused: the test runtime is fixed, and the
@@ -45,17 +49,18 @@ def init_logging_in_memory(config: Config, *, dotenv_path: str | None = None) ->
45
49
  """
46
50
  if lib_log_rich.runtime.is_initialised():
47
51
  return
48
- lib_log_rich.runtime.init(
49
- lib_log_rich.runtime.RuntimeConfig(
50
- service=f"{__init__conf__.name}-test",
51
- environment="test",
52
- console_level=LogLevel.ERROR,
53
- enable_journald=False,
54
- enable_eventlog=False,
55
- enable_graylog=False,
56
- queue_enabled=False,
52
+ with log_variables_hidden():
53
+ lib_log_rich.runtime.init(
54
+ lib_log_rich.runtime.RuntimeConfig(
55
+ service=f"{__init__conf__.name}-test",
56
+ environment="test",
57
+ console_level=LogLevel.ERROR,
58
+ enable_journald=False,
59
+ enable_eventlog=False,
60
+ enable_graylog=False,
61
+ queue_enabled=False,
62
+ )
57
63
  )
58
- )
59
64
 
60
65
 
61
66
  __all__ = ["init_logging_in_memory"]
@@ -102,6 +102,8 @@ class InitLogging(Protocol):
102
102
  """Initialize lib_log_rich runtime with the provided configuration.
103
103
 
104
104
  ``dotenv_path`` is the ``.env`` the configuration was loaded with, or None for the nearest one.
105
+ A refused logging setting raises ``InvalidLoggingConfigError`` only after logging has been
106
+ started with its defaults, so the caller never has to start it a second time.
105
107
  """
106
108
 
107
109
  def __call__(self, config: Config, *, dotenv_path: str | None = None) -> None: ...
@@ -1,7 +1,7 @@
1
1
  """The merged [email] section becomes an EmailConfig: file keys kept, unknown keys refused.
2
2
 
3
3
  lib_layered_config merges every layer into one mapping; load_email_config_from_dict reads its
4
- [email] section. Five file keys differ from ConfMail's field names, values that mean "not
4
+ [email] section. Six file keys differ from ConfMail's field names, values that mean "not
5
5
  configured" are dropped so the library default applies, and a key that is not a file key is
6
6
  refused, whatever layer it came from.
7
7
  """
@@ -33,7 +33,7 @@ def _problems(email: object) -> list[str]:
33
33
 
34
34
 
35
35
  @pytest.mark.os_agnostic
36
- def test_the_five_renamed_file_keys_reach_their_fields() -> None:
36
+ def test_the_six_renamed_file_keys_reach_their_fields() -> None:
37
37
  config = _load(
38
38
  {
39
39
  "smtp_hosts": ["smtp.example.com:587"],
@@ -41,6 +41,7 @@ def test_the_five_renamed_file_keys_reach_their_fields() -> None:
41
41
  "timeout": 12.5,
42
42
  "starttls_verify": False,
43
43
  "local_hostname": "mail.example.com",
44
+ "delivery_deadline": 120,
44
45
  }
45
46
  )
46
47
 
@@ -49,6 +50,7 @@ def test_the_five_renamed_file_keys_reach_their_fields() -> None:
49
50
  assert config.smtp_timeout == 12.5
50
51
  assert config.smtp_starttls_verify is False
51
52
  assert config.smtp_local_hostname == "mail.example.com"
53
+ assert config.smtp_delivery_deadline == 120.0
52
54
 
53
55
 
54
56
  @pytest.mark.os_agnostic
@@ -223,7 +225,7 @@ def test_a_directory_list_becomes_paths() -> None:
223
225
  "host", ["smtp.example.com:99999", "smtp.example.com:abc", "[::1"], ids=["port-range", "port-text", "bracket"]
224
226
  )
225
227
  def test_a_malformed_host_is_refused_at_load(host: str) -> None:
226
- """ConfMail refuses only userinfo, paths and control characters; the port and brackets are checked here."""
228
+ """A port or bracket typo surfaces when the configuration loads, not at the first delivery."""
227
229
  problems = _problems({"smtp_hosts": [host]})
228
230
 
229
231
  assert len(problems) == 1, problems
@@ -231,6 +233,21 @@ def test_a_malformed_host_is_refused_at_load(host: str) -> None:
231
233
  assert "Value error" not in problems[0], problems
232
234
 
233
235
 
236
+ @pytest.mark.os_agnostic
237
+ @pytest.mark.parametrize(
238
+ "host",
239
+ ["smtp.example.com:99999", "smtp.example.com:abc", "[::1", "smtp.example.com:+25", "a..example.com", "[zz]"],
240
+ ids=["port-range", "port-text", "bracket", "port-sign", "empty-label", "bracket-not-ip"],
241
+ )
242
+ def test_conf_mail_itself_refuses_a_malformed_host(host: str) -> None:
243
+ """EmailConfig has no host check of its own: ConfMail runs validate_smtp_host on every entry.
244
+
245
+ If a btx_lib_mail release stops doing so, this fails, and the check belongs back in EmailConfig.
246
+ """
247
+ with pytest.raises(ValidationError):
248
+ ConfMail(smtphosts=[host])
249
+
250
+
234
251
  @pytest.mark.os_agnostic
235
252
  def test_a_host_list_of_the_wrong_type_reads_as_the_file_key_without_a_prefix() -> None:
236
253
  """btx_lib_mail drops ctx for a credential field, so pydantic's prefix is still on the message."""
@@ -5,7 +5,9 @@ reach the process environment: a later configuration load (``config --profile``,
5
5
  permission read) would take an app-prefixed line for the environment layer. With ``--env-file``,
6
6
  that file is the only ``.env`` read. An invalid ``[lib_log_rich]`` section is a configuration
7
7
  failure like a broken file: the commands that read the configuration refuse with exit 78, the
8
- others still run.
8
+ others still run, logging with its defaults and every valid ``LOG_*`` variable. A refused ``LOG_*``
9
+ variable, from the environment or the ``.env``, is the same failure: logging still starts, with its
10
+ defaults and without any ``LOG_*`` variable.
9
11
 
10
12
  The end-to-end tests run the real CLI in a subprocess with every configuration location under
11
13
  ``tmp_path``.
@@ -23,7 +25,7 @@ import pytest
23
25
  from lib_layered_config import Config
24
26
 
25
27
  from bitranox_template_py_cli import __init__conf__
26
- from bitranox_template_py_cli.adapters.logging.setup import init_logging
28
+ from bitranox_template_py_cli.adapters.logging.setup import InvalidLoggingConfigError, init_logging
27
29
 
28
30
  if TYPE_CHECKING:
29
31
  from collections.abc import Iterator
@@ -202,3 +204,128 @@ def test_an_invalid_logging_section_does_not_block_the_deploy_that_replaces_it(t
202
204
  )
203
205
 
204
206
  assert result.returncode == 0, result.stderr
207
+
208
+
209
+ # ------------------------------------------------------------------ a refused setting still leaves logging running
210
+
211
+
212
+ @pytest.mark.os_agnostic
213
+ def test_a_refused_logging_section_still_leaves_logging_running() -> None:
214
+ with pytest.raises(InvalidLoggingConfigError):
215
+ init_logging(Config({"lib_log_rich": {"rate_limit": "100:60"}}, {}))
216
+
217
+ assert lib_log_rich.runtime.is_initialised()
218
+
219
+
220
+ @pytest.mark.os_agnostic
221
+ def test_only_the_first_call_raises_for_a_refused_logging_section() -> None:
222
+ """The fallback leaves logging running, so a later call returns before it checks anything."""
223
+ refused = Config({"lib_log_rich": {"rate_limit": "100:60"}}, {})
224
+ with pytest.raises(InvalidLoggingConfigError):
225
+ init_logging(refused)
226
+
227
+ init_logging(refused)
228
+
229
+ assert lib_log_rich.runtime.is_initialised()
230
+
231
+
232
+ @pytest.mark.os_agnostic
233
+ def test_a_refused_log_variable_still_leaves_logging_running(monkeypatch: pytest.MonkeyPatch) -> None:
234
+ """lib_log_rich reads every ``LOG_*`` variable on each init, so the fallback must start without them."""
235
+ monkeypatch.setenv("LOG_CONSOLE_LEVEL", "bogus")
236
+
237
+ with pytest.raises(InvalidLoggingConfigError):
238
+ init_logging(Config({}, {}))
239
+
240
+ assert lib_log_rich.runtime.is_initialised()
241
+ assert os.environ["LOG_CONSOLE_LEVEL"] == "bogus"
242
+
243
+
244
+ @pytest.mark.os_agnostic
245
+ @pytest.mark.parametrize(
246
+ "command", [("info",), ("config-deploy", "--target", "user", "--force")], ids=["info", "deploy"]
247
+ )
248
+ def test_a_refused_log_variable_in_the_env_file_leaves_commands_that_do_not_read_the_config_running(
249
+ tmp_path: Path, command: tuple[str, ...]
250
+ ) -> None:
251
+ env_file = tmp_path / "service.env"
252
+ env_file.write_text("LOG_CONSOLE_LEVEL=bogus\n", encoding="utf-8")
253
+
254
+ result = _run(tmp_path, "--env-file", str(env_file), *command)
255
+
256
+ assert result.returncode == 0, result.stderr
257
+
258
+
259
+ @pytest.mark.os_agnostic
260
+ def test_a_refused_log_variable_in_the_env_file_is_a_configuration_error(tmp_path: Path) -> None:
261
+ env_file = tmp_path / "service.env"
262
+ env_file.write_text("LOG_CONSOLE_LEVEL=bogus\n", encoding="utf-8")
263
+
264
+ result = _run(tmp_path, "--env-file", str(env_file), "config")
265
+
266
+ assert result.returncode == 78, result.stderr
267
+ errors = [line for line in result.stderr.splitlines() if line.startswith("Error:")]
268
+ assert len(errors) == 1, result.stderr
269
+ assert errors[0].startswith("Error: lib_log_rich")
270
+
271
+
272
+ @pytest.mark.os_agnostic
273
+ def test_a_refused_log_variable_in_the_environment_leaves_info_running(tmp_path: Path) -> None:
274
+ result = _run(tmp_path, "info", env={"LOG_CONSOLE_LEVEL": "bogus"})
275
+
276
+ assert result.returncode == 0, result.stderr
277
+
278
+
279
+ @pytest.mark.os_agnostic
280
+ def test_every_log_variable_comes_back_even_when_the_default_start_fails(monkeypatch: pytest.MonkeyPatch) -> None:
281
+ """The fallback start hides the ``LOG_*`` variables; a failure there must not lose them.
282
+
283
+ ``lib_log_rich.runtime.init`` is the third-party edge: the first call refuses like a bad
284
+ level, the second (the defaults) fails for a reason of its own.
285
+ """
286
+ monkeypatch.setenv("LOG_CONSOLE_LEVEL", "bogus")
287
+ variable_seen: list[bool] = []
288
+
289
+ def refuse(_config: object) -> None:
290
+ variable_seen.append("LOG_CONSOLE_LEVEL" in os.environ)
291
+ if "LOG_CONSOLE_LEVEL" in os.environ:
292
+ raise ValueError("Unknown log level: 'bogus'")
293
+ raise RuntimeError("the default start failed too")
294
+
295
+ monkeypatch.setattr(lib_log_rich.runtime, "init", refuse)
296
+
297
+ with pytest.raises(RuntimeError, match="the default start failed too"):
298
+ init_logging(Config({}, {}))
299
+
300
+ # The configuration, then the defaults with the variables, then the defaults without them.
301
+ assert variable_seen == [True, True, False]
302
+ assert os.environ["LOG_CONSOLE_LEVEL"] == "bogus"
303
+
304
+
305
+ # ------------------------------------------------------------------ which LOG_* variables the fallback keeps
306
+
307
+ _INFO_LINE = "Displaying package information"
308
+ _REFUSED_SECTION = ("--set", "lib_log_rich.rate_limit=100:60")
309
+
310
+
311
+ @pytest.mark.os_agnostic
312
+ def test_a_valid_log_variable_is_still_honoured_when_the_logging_section_is_refused(tmp_path: Path) -> None:
313
+ """Only a refused ``LOG_*`` variable is a reason to start without them; a refused section is not."""
314
+ shown = _run(tmp_path, *_REFUSED_SECTION, "info")
315
+ quieted = _run(tmp_path, *_REFUSED_SECTION, "info", env={"LOG_CONSOLE_LEVEL": "error"})
316
+
317
+ # Premise: at the default console level the fallback shows info's INFO line.
318
+ assert shown.returncode == 0, shown.stderr
319
+ assert _INFO_LINE in shown.stderr, shown.stderr
320
+ assert quieted.returncode == 0, quieted.stderr
321
+ assert _INFO_LINE not in quieted.stderr, quieted.stderr
322
+
323
+
324
+ @pytest.mark.os_agnostic
325
+ @pytest.mark.parametrize("section", [(), _REFUSED_SECTION], ids=["variable-alone", "with-refused-section"])
326
+ def test_a_refused_log_variable_is_hidden_and_the_fallback_still_logs(tmp_path: Path, section: tuple[str, ...]) -> None:
327
+ """The defaults start without every ``LOG_*`` variable, and std logging still reaches the console."""
328
+ result = _run(tmp_path, *section, "info", env={"LOG_CONSOLE_LEVEL": "bogus"})
329
+
330
+ assert result.returncode == 0, result.stderr
331
+ assert _INFO_LINE in result.stderr, result.stderr
@@ -15,6 +15,7 @@ from unittest.mock import patch
15
15
 
16
16
  import pytest
17
17
  import rtoml
18
+ from btx_lib_mail import ConfMail
18
19
  from pydantic import SecretStr
19
20
  from pydantic import ValidationError as PydanticValidationError
20
21
 
@@ -46,6 +47,7 @@ class _Delivery:
46
47
  credentials: tuple[str, str] | None
47
48
  local_hostname: str | None
48
49
  starttls_verify: bool
50
+ deadline: float | None
49
51
 
50
52
 
51
53
  class RecordingTransport:
@@ -87,6 +89,7 @@ class RecordingTransport:
87
89
  credentials=delivery.credentials,
88
90
  local_hostname=delivery.local_hostname,
89
91
  starttls_verify=delivery.starttls_verify,
92
+ deadline=delivery.deadline,
90
93
  )
91
94
  )
92
95
 
@@ -1346,6 +1349,55 @@ def test_starttls_verify_and_local_hostname_from_the_file_reach_the_transport()
1346
1349
  assert transport.deliveries[0].starttls_verify is False
1347
1350
 
1348
1351
 
1352
+ @pytest.mark.os_agnostic
1353
+ def test_delivery_deadline_from_the_file_reaches_the_transport() -> None:
1354
+ config = load_email_config_from_dict(
1355
+ {"email": {"smtp_hosts": ["smtp.test.com:587"], "from_address": "app@example.com", "delivery_deadline": 90}}
1356
+ )
1357
+ transport = RecordingTransport()
1358
+
1359
+ send_email(config=config, recipients="ops@example.com", subject="s", transport=transport)
1360
+
1361
+ assert transport.deliveries[0].deadline == 90.0
1362
+
1363
+
1364
+ @pytest.mark.os_agnostic
1365
+ def test_recipient_max_count_from_the_file_refuses_a_larger_send_before_any_delivery() -> None:
1366
+ config = load_email_config_from_dict(
1367
+ {"email": {"smtp_hosts": ["smtp.test.com:587"], "from_address": "app@example.com", "recipient_max_count": 1}}
1368
+ )
1369
+ transport = RecordingTransport()
1370
+
1371
+ with pytest.raises(ValueError, match=r"2 recipients, more than recipient_max_count \(1\)"):
1372
+ send_email(config=config, recipients=["a@example.com", "b@example.com"], subject="s", transport=transport)
1373
+
1374
+ assert transport.attempted_hosts == []
1375
+
1376
+
1377
+ @pytest.mark.os_agnostic
1378
+ def test_attachments_max_count_from_the_file_refuses_a_larger_send_before_any_delivery(tmp_path: Path) -> None:
1379
+ first, second = tmp_path / "a.txt", tmp_path / "b.txt"
1380
+ first.write_text("a", encoding="utf-8")
1381
+ second.write_text("b", encoding="utf-8")
1382
+ config = load_email_config_from_dict(
1383
+ {
1384
+ "email": {
1385
+ "smtp_hosts": ["smtp.test.com:587"],
1386
+ "from_address": "app@example.com",
1387
+ "attachments": {"max_count": 1},
1388
+ }
1389
+ }
1390
+ )
1391
+ transport = RecordingTransport()
1392
+
1393
+ with pytest.raises(ValueError, match=r"2 attachments, more than attachment_max_count \(1\)"):
1394
+ send_email(
1395
+ config=config, recipients="ops@example.com", subject="s", attachments=[first, second], transport=transport
1396
+ )
1397
+
1398
+ assert transport.attempted_hosts == []
1399
+
1400
+
1349
1401
  _SHIPPED_MAIL = (
1350
1402
  Path(__file__).parent.parent / "src" / "bitranox_template_py_cli" / "adapters" / "config" / "defaultconfig.d"
1351
1403
  ) / "50-mail.toml"
@@ -1356,3 +1408,18 @@ def test_the_shipped_defaults_verify_the_certificate_and_name_no_ehlo_host() ->
1356
1408
  shipped = rtoml.load(_SHIPPED_MAIL)["email"]
1357
1409
  assert shipped["starttls_verify"] is True
1358
1410
  assert shipped["local_hostname"] == ""
1411
+
1412
+
1413
+ @pytest.mark.os_agnostic
1414
+ def test_the_shipped_count_limits_and_deadline_load_as_the_library_defaults() -> None:
1415
+ shipped = rtoml.load(_SHIPPED_MAIL)
1416
+ # The keys are written out, so a reader of the shipped file sees them: the premise.
1417
+ assert "recipient_max_count" in shipped["email"]
1418
+ assert "delivery_deadline" in shipped["email"]
1419
+ assert "max_count" in shipped["email"]["attachments"]
1420
+ loaded = load_email_config_from_dict(shipped)
1421
+ library = ConfMail()
1422
+
1423
+ assert loaded.recipient_max_count == library.recipient_max_count
1424
+ assert loaded.attachment_max_count == library.attachment_max_count
1425
+ assert loaded.smtp_delivery_deadline is library.smtp_delivery_deadline is None
@@ -11,6 +11,7 @@ reset within one test instead of relying on test order.
11
11
  from __future__ import annotations
12
12
 
13
13
  import logging
14
+ import os
14
15
  from pathlib import Path
15
16
  from typing import TYPE_CHECKING, Any
16
17
 
@@ -71,6 +72,30 @@ def test_the_reset_restores_the_root_logger_a_production_init_changed(
71
72
  assert (list(root.handlers), root.level, root.propagate) == before
72
73
 
73
74
 
75
+ @pytest.mark.os_agnostic
76
+ def test_a_refused_log_variable_in_the_shell_does_not_break_build_testing(
77
+ cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch
78
+ ) -> None:
79
+ """lib_log_rich reads the ``LOG_*`` variables on every init, so a developer's own
80
+ ``LOG_CONSOLE_LEVEL=bogus`` would refuse the test runtime and fail every command."""
81
+ monkeypatch.setenv("LOG_CONSOLE_LEVEL", "bogus")
82
+
83
+ result = cli_runner.invoke(cli_mod.cli, ["info"], obj=build_testing)
84
+
85
+ assert result.exception is None, result.exception
86
+ assert result.exit_code == 0, result.output
87
+
88
+
89
+ @pytest.mark.os_agnostic
90
+ def test_the_testing_runtime_puts_the_log_variables_back(monkeypatch: pytest.MonkeyPatch) -> None:
91
+ monkeypatch.setenv("LOG_CONSOLE_LEVEL", "bogus")
92
+
93
+ build_testing().init_logging(Config({}, {}))
94
+
95
+ assert lib_log_rich.runtime.is_initialised() is True
96
+ assert os.environ.get("LOG_CONSOLE_LEVEL") == "bogus"
97
+
98
+
74
99
  def _deploy_nothing(**_kwargs: Any) -> list[Path]:
75
100
  return []
76
101