bitranox-template-py-cli 2.0.1__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.0.1 → bitranox_template_py_cli-2.1.1}/.env.example +14 -0
  2. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/CHANGELOG.md +42 -0
  3. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/PKG-INFO +5 -4
  4. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/README.md +3 -2
  5. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/docs/systemdesign/module_reference.md +12 -8
  6. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/pyproject.toml +2 -2
  7. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
  8. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/_common.py +2 -2
  9. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/config_load.py +9 -5
  10. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +28 -3
  11. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/config.py +10 -14
  12. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/transport.py +5 -3
  13. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/setup.py +62 -14
  14. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/logging.py +16 -11
  15. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/ports.py +2 -0
  16. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_email_config_translation.py +51 -3
  17. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_logging_dotenv_isolation.py +129 -2
  18. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_mail.py +67 -0
  19. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_memory_logging.py +25 -0
  20. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.devcontainer/devcontainer.json +0 -0
  21. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.devcontainer/settings.json +0 -0
  22. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.gitattributes +0 -0
  23. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.github/actions/extract-metadata/action.yml +0 -0
  24. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.github/dependabot.yml +0 -0
  25. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.github/workflows/codeql.yml +0 -0
  26. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.github/workflows/default_cicd_public.yml +0 -0
  27. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.github/workflows/default_release_public.yml +0 -0
  28. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.gitignore +0 -0
  29. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.qlty/qlty.toml +0 -0
  30. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.snyk +0 -0
  31. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/CONFIG.md +0 -0
  32. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/CONTRIBUTING.md +0 -0
  33. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/DEVELOPMENT.md +0 -0
  34. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/INSTALL.md +0 -0
  35. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/LICENSE +0 -0
  36. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/Makefile +0 -0
  37. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/SECURITY.md +0 -0
  38. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/ai-stance.md +0 -0
  39. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/ai-transparency.md +0 -0
  40. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/codecov.yml +0 -0
  41. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/docs/adr/0001-memory-adapters-in-src.md +0 -0
  42. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/notebooks/Quickstart.ipynb +0 -0
  43. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/rename.sh +0 -0
  44. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/rename_dry.sh +0 -0
  45. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/reset_git_history.sh +0 -0
  46. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__init__.py +0 -0
  47. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__main__.py +0 -0
  48. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
  49. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
  50. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
  51. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/config.py +0 -0
  52. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/__init__.py +0 -0
  53. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_email.py +0 -0
  54. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_notification.py +0 -0
  55. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
  56. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
  57. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
  58. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
  59. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +0 -0
  60. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
  61. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
  62. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
  63. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/safe_console.py +0 -0
  64. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/typed_click.py +0 -0
  65. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
  66. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/40-layered-config.toml +0 -0
  67. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/90-logging.toml +0 -0
  68. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
  69. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
  70. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
  71. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
  72. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/overrides.py +0 -0
  73. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
  74. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
  75. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
  76. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
  77. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
  78. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
  79. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
  80. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/__init__.py +0 -0
  81. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
  82. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/email.py +0 -0
  83. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
  84. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/__init__.py +0 -0
  85. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/py.typed +0 -0
  86. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/composition/__init__.py +0 -0
  87. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/composition/py.typed +0 -0
  88. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
  89. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
  90. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/enums.py +0 -0
  91. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/errors.py +0 -0
  92. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/py.typed +0 -0
  93. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/entry.py +0 -0
  94. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/py.typed +0 -0
  95. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/conftest.py +0 -0
  96. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_behaviors.py +0 -0
  97. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cache_effectiveness.py +0 -0
  98. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_config.py +0 -0
  99. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_config_errors.py +0 -0
  100. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_core.py +0 -0
  101. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_email.py +0 -0
  102. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_email_config_errors.py +0 -0
  103. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_env_file.py +0 -0
  104. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_exit_codes.py +0 -0
  105. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_main_exit.py +0 -0
  106. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_overrides.py +0 -0
  107. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_validation.py +0 -0
  108. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_config_overrides.py +0 -0
  109. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_declared_dependencies.py +0 -0
  110. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_deploy_mode_safety.py +0 -0
  111. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_deploy_permissions.py +0 -0
  112. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_display.py +0 -0
  113. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_email_attachment_lists.py +0 -0
  114. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_email_password_secrecy.py +0 -0
  115. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_email_shipped_defaults.py +0 -0
  116. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_enums.py +0 -0
  117. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_errors.py +0 -0
  118. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_logging.py +0 -0
  119. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_metadata.py +0 -0
  120. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_metadata_sync.py +0 -0
  121. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_module_entry.py +0 -0
  122. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_module_reference_sync.py +0 -0
  123. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_permission_defaults.py +0 -0
  124. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_ports.py +0 -0
  125. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_property_email.py +0 -0
  126. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_property_overrides.py +0 -0
  127. {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_safe_console.py +0 -0
@@ -101,6 +101,20 @@ GITHUB_TOKEN=
101
101
  # .env: EMAIL__LOCAL_HOSTNAME=mail.example.com
102
102
  # EMAIL__LOCAL_HOSTNAME=mail.example.com
103
103
 
104
+ # Purpose: Upper bound in seconds for one SMTP session (connect to QUIT); 0 for none
105
+ # Type: float
106
+ # Default: 0 (none)
107
+ # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__DELIVERY_DEADLINE=300
108
+ # .env: EMAIL__DELIVERY_DEADLINE=300
109
+ # EMAIL__DELIVERY_DEADLINE=300
110
+
111
+ # Purpose: Most recipients one send accepts; 0 for no limit
112
+ # Type: integer
113
+ # Default: 1000
114
+ # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__RECIPIENT_MAX_COUNT=1000
115
+ # .env: EMAIL__RECIPIENT_MAX_COUNT=1000
116
+ # EMAIL__RECIPIENT_MAX_COUNT=1000
117
+
104
118
  # Purpose: Raise FileNotFoundError when attachment files are missing
105
119
  # Type: boolean (true/false)
106
120
  # Default: true
@@ -6,6 +6,48 @@ 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
+
30
+ ## [2.1.0] 2026-10-06 15:37:27
31
+
32
+ ### Added
33
+ - **Three email limits from btx_lib_mail 4.0.0 are configurable**: `[email] delivery_deadline`
34
+ (an upper bound in seconds for one SMTP session; the field is `smtp_delivery_deadline`),
35
+ `[email] recipient_max_count` (default 1000) and `[email.attachments] max_count` (default
36
+ 100). A value of 0 means no limit, as `max_size_bytes = 0` already did. Shipped in
37
+ `50-mail.toml`, `.env.example` and the module reference.
38
+
39
+ ### Changed
40
+ - **Requires `btx_lib_mail>=4.0.0`.** Effects a user of the email commands can see: the
41
+ Windows dangerous extensions (`.exe`, `.bat`, `.ps1`, ...) are refused on every platform by
42
+ default and the POSIX ones on Windows; a sender or recipient longer than RFC 5321 allows is
43
+ refused; a subject with a control character or over 4096 characters is refused; an
44
+ attachment swapped after its checks is refused; a delivery error is a `DeliveryError` (still
45
+ a `RuntimeError`). See the btx_lib_mail 4.0.0 changelog for the full list.
46
+
47
+ ### Removed
48
+ - **`EmailConfig`'s own SMTP host validator.** `ConfMail` checks every host's syntax (port
49
+ range, IPv6 brackets) itself since btx_lib_mail 3.0.1; the messages are unchanged.
50
+
9
51
  ## [2.0.1] 2026-10-06 13:09:50
10
52
 
11
53
  Re-release of 2.0.0, which was tagged but never published: PyPI rejected the upload because
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bitranox_template_py_cli
3
- Version: 2.0.1
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
@@ -21,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
21
21
  Classifier: Programming Language :: Python :: 3.14
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.10
24
- Requires-Dist: btx-lib-mail>=3.0.1
24
+ Requires-Dist: btx-lib-mail>=4.0.0
25
25
  Requires-Dist: click>=8.5.0
26
26
  Requires-Dist: lib-cli-exit-tools>=2.4.0
27
27
  Requires-Dist: lib-layered-config>=7.0.1
@@ -286,6 +286,7 @@ use_starttls = true
286
286
  starttls_verify = true # false accepts any certificate: only for a known internal relay
287
287
  timeout = 60.0
288
288
  # local_hostname = "mail.example.com" # EHLO name; set it where reverse DNS is slow
289
+ # delivery_deadline = 300 # upper bound in seconds for one SMTP session; 0 = none
289
290
  ```
290
291
 
291
292
  A key `[email]` or `[email.attachments]` does not know is refused (exit 78,
@@ -378,8 +379,8 @@ from bitranox_template_py_cli.composition import send_email, send_notification
378
379
 
379
380
  # Configure email. EmailConfig is a btx_lib_mail ConfMail, so in Python the fields use the
380
381
  # library's names (smtphosts, smtp_use_starttls, smtp_timeout, smtp_starttls_verify,
381
- # smtp_local_hostname); configuration files keep their keys (smtp_hosts, use_starttls, timeout,
382
- # starttls_verify, local_hostname). smtp_password is a SecretStr: it prints as
382
+ # smtp_local_hostname, smtp_delivery_deadline); configuration files keep their keys (smtp_hosts,
383
+ # use_starttls, timeout, starttls_verify, local_hostname, delivery_deadline). smtp_password is a SecretStr: it prints as
383
384
  # '**********' in logs, reprs and dumps, and config.smtp_password.get_secret_value() returns
384
385
  # the plain text.
385
386
  config = EmailConfig(
@@ -231,6 +231,7 @@ use_starttls = true
231
231
  starttls_verify = true # false accepts any certificate: only for a known internal relay
232
232
  timeout = 60.0
233
233
  # local_hostname = "mail.example.com" # EHLO name; set it where reverse DNS is slow
234
+ # delivery_deadline = 300 # upper bound in seconds for one SMTP session; 0 = none
234
235
  ```
235
236
 
236
237
  A key `[email]` or `[email.attachments]` does not know is refused (exit 78,
@@ -323,8 +324,8 @@ from bitranox_template_py_cli.composition import send_email, send_notification
323
324
 
324
325
  # Configure email. EmailConfig is a btx_lib_mail ConfMail, so in Python the fields use the
325
326
  # library's names (smtphosts, smtp_use_starttls, smtp_timeout, smtp_starttls_verify,
326
- # smtp_local_hostname); configuration files keep their keys (smtp_hosts, use_starttls, timeout,
327
- # starttls_verify, local_hostname). smtp_password is a SecretStr: it prints as
327
+ # smtp_local_hostname, smtp_delivery_deadline); configuration files keep their keys (smtp_hosts,
328
+ # use_starttls, timeout, starttls_verify, local_hostname, delivery_deadline). smtp_password is a SecretStr: it prints as
328
329
  # '**********' in logs, reprs and dumps, and config.smtp_password.get_secret_value() returns
329
330
  # the plain text.
330
331
  config = EmailConfig(
@@ -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
  |--------------------------------|---------------------|---------|--------------------------------|------------------------------------------------------------------|
@@ -337,6 +338,8 @@ column).
337
338
  | `smtp_starttls_verify` | `bool` | `True` | `starttls_verify` | Verify the server certificate after STARTTLS |
338
339
  | `smtp_timeout` | `float` | `30.0` | `timeout` | Socket timeout in seconds |
339
340
  | `smtp_local_hostname` | `str \| None` | `None` | `local_hostname` | Host name announced in EHLO; `None` looks it up once per process |
341
+ | `smtp_delivery_deadline` | `float \| None` | `None` | `delivery_deadline` | Upper bound in seconds for one SMTP session; `None` for none |
342
+ | `recipient_max_count` | `int \| None` | `1000` | `recipient_max_count` | Most recipients one send accepts; `None` lifts the limit |
340
343
  | `raise_on_missing_attachments` | `bool` | `True` | `raise_on_missing_attachments` | Raise on missing attachment files |
341
344
  | `raise_on_invalid_recipient` | `bool` | `True` | `raise_on_invalid_recipient` | Raise on invalid recipient addresses |
342
345
 
@@ -345,10 +348,11 @@ column).
345
348
  | Field | Type | Default | File key (`[email.attachments]`) | Description |
346
349
  |------------------------------------------|---------------------------|--------------|----------------------------------|---------------------------------------------------------|
347
350
  | `attachment_allowed_extensions` | `frozenset[str] \| None` | `None` | `allowed_extensions` | Whitelist of allowed extensions |
348
- | `attachment_blocked_extensions` | `frozenset[str]` | OS defaults | `blocked_extensions` | Blacklist of blocked extensions |
351
+ | `attachment_blocked_extensions` | `frozenset[str]` | POSIX + Win | `blocked_extensions` | Blacklist of blocked extensions (both lists, every OS) |
349
352
  | `attachment_allowed_directories` | `frozenset[Path] \| None` | `None` | `allowed_directories` | Whitelist of allowed source directories |
350
353
  | `attachment_blocked_directories` | `frozenset[Path]` | OS defaults | `blocked_directories` | Blacklist of blocked directories |
351
354
  | `attachment_max_size_bytes` | `int \| None` | `26_214_400` | `max_size_bytes` | Maximum file size (25 MiB), `None` to disable |
355
+ | `attachment_max_count` | `int \| None` | `100` | `max_count` | Most attachments one send accepts, `None` to disable |
352
356
  | `attachment_allow_symlinks` | `bool` | `False` | `allow_symlinks` | Whether symlinks are permitted |
353
357
  | `attachment_raise_on_security_violation` | `bool` | `True` | `raise_on_security_violation` | Raise or skip on security violation |
354
358
  | `attachment_allow_empty_blocklists` | `bool` | `False` | not exposed | Allow an empty blocked set (blocks nothing) from Python |
@@ -359,13 +363,13 @@ lib_layered_config is the only reader of configuration: it merges every layer (t
359
363
  defaults, the app, host and user files, `.env`, the environment, `--set`) into one mapping.
360
364
  `load_email_config_from_dict()` turns that mapping's `[email]` section into an `EmailConfig`:
361
365
 
362
- - the five 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;
363
367
  - `[email.attachments]` keys become `attachment_<key>`;
364
368
  - a key that is not listed is refused (exit 78, `email.<key>: unknown key`), whatever layer it came from;
365
369
  - blank text means "not configured"; a single host or address string is a one-entry list;
366
370
  - an empty attachment list (`[]` or a blank string) means the library's defaults, and any other
367
371
  value that is not a list, such as a comma-separated string, is refused, as is a blank entry;
368
- - `max_size_bytes = 0` means no size limit.
372
+ - a limit of 0 (`max_size_bytes`, `max_count`, `recipient_max_count`, `delivery_deadline`) means no limit.
369
373
 
370
374
  `describe_validation_error()` renders a refusal as one `email.<file key>: <reason>` line per
371
375
  problem, never with the refused value.
@@ -376,7 +380,7 @@ problem, never with the refused value.
376
380
  |---------------------|------------------------------------------------------------------------------|
377
381
  | `SECTION_KEYS` | Every key `[email]` accepts (the `attachments` table included) |
378
382
  | `ATTACHMENT_KEYS` | Every key `[email.attachments]` accepts; each names field `attachment_<key>` |
379
- | `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 |
380
384
 
381
385
  ---
382
386
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bitranox_template_py_cli"
3
- version = "2.0.1"
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"
@@ -12,7 +12,7 @@ dependencies = [
12
12
  "lib_log_rich>=6.3.9",
13
13
  "python-dotenv>=1.2.4",
14
14
  "lib_layered_config>=7.0.1",
15
- "btx_lib_mail>=3.0.1",
15
+ "btx_lib_mail>=4.0.0",
16
16
  "pydantic>=2.13.5",
17
17
  "orjson>=3.12.0",
18
18
  ]
@@ -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.0.1"
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
 
@@ -110,6 +110,23 @@ starttls_verify = true
110
110
  # qualified name here.
111
111
  local_hostname = ""
112
112
 
113
+ # Purpose: Upper bound in seconds for one SMTP session, from the connect to QUIT
114
+ # Type: float, or 0 for no deadline
115
+ # Default: 0 (none; only the socket timeout bounds each read or write)
116
+ # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__DELIVERY_DEADLINE=300
117
+ # .env: EMAIL__DELIVERY_DEADLINE=300
118
+ # Note: The socket timeout restarts with every byte, so a server answering one byte at a
119
+ # time can hold a session open indefinitely; past the deadline that host counts as failed.
120
+ delivery_deadline = 0
121
+
122
+ # Purpose: Most recipients one send accepts (counted after duplicates are dropped)
123
+ # Type: integer, or 0 for no limit
124
+ # Default: 1000
125
+ # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__RECIPIENT_MAX_COUNT=1000
126
+ # .env: EMAIL__RECIPIENT_MAX_COUNT=1000
127
+ # Note: More recipients are refused before anything is delivered
128
+ recipient_max_count = 1000
129
+
113
130
  # Purpose: Behavior when attachment files are missing
114
131
  # Type: boolean
115
132
  # Default: true (raise FileNotFoundError)
@@ -152,9 +169,9 @@ allowed_extensions = []
152
169
 
153
170
  # Purpose: Blacklist of blocked file extensions (ignored when allowed_extensions is set)
154
171
  # Type: array of strings
155
- # Default: [] (the library's defaults apply: btx_lib_mail DANGEROUS_EXTENSIONS_POSIX /
156
- # DANGEROUS_EXTENSIONS_WINDOWS; paths matching SENSITIVE_PATH_PATTERNS such as /.ssh/ are
157
- # always refused)
172
+ # Default: [] (the library's defaults apply: btx_lib_mail DANGEROUS_EXTENSIONS_POSIX plus
173
+ # DANGEROUS_EXTENSIONS_WINDOWS on every platform; paths matching SENSITIVE_PATH_PATTERNS such
174
+ # as /.ssh/ are always refused)
158
175
  # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__ATTACHMENTS__BLOCKED_EXTENSIONS='[".exe",".bat",".cmd"]'
159
176
  # .env: EMAIL__ATTACHMENTS__BLOCKED_EXTENSIONS=[".exe",".bat",".cmd"] (unquoted)
160
177
  # A comma-separated value is NOT a list: it arrives as one string and is refused
@@ -197,6 +214,14 @@ blocked_directories = []
197
214
  # Note: Set to 0 to disable size checking entirely
198
215
  max_size_bytes = 26214400
199
216
 
217
+ # Purpose: Most attachments one send accepts
218
+ # Type: integer, or 0 for no limit
219
+ # Default: 100
220
+ # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__ATTACHMENTS__MAX_COUNT=100
221
+ # .env: EMAIL__ATTACHMENTS__MAX_COUNT=100
222
+ # Note: More attachments are refused before any file is checked or anything is delivered
223
+ max_count = 100
224
+
200
225
  # Purpose: Whether to allow symbolic links as attachments
201
226
  # Type: boolean
202
227
  # Default: false (symlinks are rejected)
@@ -3,7 +3,7 @@
3
3
  lib_layered_config is the only reader of configuration. It merges every layer (the shipped
4
4
  defaults, the app, host and user files, ``.env``, the environment and ``--set``) into one
5
5
  mapping, and :func:`load_email_config_from_dict` turns that mapping's ``[email]`` section into
6
- :class:`EmailConfig`. Operators keep writing the file keys they always wrote; five of them
6
+ :class:`EmailConfig`. Operators keep writing the file keys they always wrote; six of them
7
7
  differ from the field names code reads (``FILE_KEY_TO_FIELD``). A key that is not a file key
8
8
  is refused, so a typo cannot leave a setting silently at its default.
9
9
  """
@@ -14,7 +14,7 @@ import re
14
14
  from collections.abc import Mapping
15
15
  from typing import TYPE_CHECKING, Any, cast
16
16
 
17
- from btx_lib_mail import ConfMail, validate_email_address, validate_smtp_host
17
+ from btx_lib_mail import ConfMail, validate_email_address
18
18
  from pydantic import ConfigDict, Field, SecretStr, ValidationError, field_validator
19
19
 
20
20
  if TYPE_CHECKING:
@@ -34,6 +34,7 @@ FILE_KEY_TO_FIELD: dict[str, str] = {
34
34
  "timeout": "smtp_timeout",
35
35
  "starttls_verify": "smtp_starttls_verify",
36
36
  "local_hostname": "smtp_local_hostname",
37
+ "delivery_deadline": "smtp_delivery_deadline",
37
38
  }
38
39
  _FIELD_TO_FILE_KEY: dict[str, str] = {field: key for key, field in FILE_KEY_TO_FIELD.items()}
39
40
 
@@ -47,6 +48,7 @@ SECTION_KEYS = frozenset(
47
48
  "smtp_password",
48
49
  "raise_on_missing_attachments",
49
50
  "raise_on_invalid_recipient",
51
+ "recipient_max_count",
50
52
  _ATTACHMENTS,
51
53
  }
52
54
  )
@@ -59,6 +61,7 @@ ATTACHMENT_KEYS = frozenset(
59
61
  "allowed_directories",
60
62
  "blocked_directories",
61
63
  "max_size_bytes",
64
+ "max_count",
62
65
  "allow_symlinks",
63
66
  "raise_on_security_violation",
64
67
  }
@@ -74,6 +77,9 @@ _BLANK_TEXT_MEANS_UNSET = frozenset(
74
77
  _EMPTY_LIST_MEANS_DEFAULT = frozenset(
75
78
  {"allowed_extensions", "blocked_extensions", "allowed_directories", "blocked_directories"}
76
79
  )
80
+ #: Limits whose 0 means "no limit". ConfMail refuses 0 and lifts a limit with None, which a
81
+ #: TOML file or an environment variable cannot write.
82
+ _ZERO_MEANS_NO_LIMIT = frozenset({"max_size_bytes", "max_count", "recipient_max_count", "delivery_deadline"})
77
83
  #: The refusal of a blank entry in an attachment list.
78
84
  _BLANK_ENTRY = ValueError("an entry is blank; remove it")
79
85
  #: The form an attachment list must take, named by the refusal of any other form. A
@@ -146,16 +152,6 @@ class EmailConfig(ConfMail):
146
152
  return [value] if value.strip() else []
147
153
  return value
148
154
 
149
- @field_validator("smtphosts")
150
- @classmethod
151
- def _check_hosts(cls, value: list[str]) -> list[str]:
152
- # ConfMail refuses userinfo, a path and control characters; the port range and the
153
- # IPv6 brackets are checked only by validate_smtp_host, so a typo surfaces at load
154
- # time rather than at the first delivery.
155
- for host in value:
156
- validate_smtp_host(host)
157
- return value
158
-
159
155
  @field_validator("from_address")
160
156
  @classmethod
161
157
  def _check_from_address(cls, value: str | None) -> str | None:
@@ -273,10 +269,10 @@ def _means_unset(key: str, value: object) -> bool:
273
269
 
274
270
 
275
271
  def _field_value(key: str, value: object) -> object:
276
- """A lone host or address becomes a one-entry list; a size limit of 0 means no limit."""
272
+ """A lone host or address becomes a one-entry list; a limit of 0 means no limit."""
277
273
  if key in _ONE_OR_MANY and isinstance(value, str):
278
274
  return [value]
279
- if key == "max_size_bytes" and type(value) in (int, float) and value == 0:
275
+ if key in _ZERO_MEANS_NO_LIMIT and type(value) in (int, float) and value == 0:
280
276
  return None
281
277
  return value
282
278
 
@@ -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: ...