bitranox-template-py-cli 2.1.1__tar.gz → 2.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/CHANGELOG.md +29 -0
  2. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/PKG-INFO +2 -2
  3. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/docs/systemdesign/module_reference.md +19 -17
  4. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/pyproject.toml +2 -2
  5. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
  6. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/config.py +5 -2
  7. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/email/_common.py +28 -2
  8. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/config_load.py +33 -1
  9. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +3 -0
  10. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +2 -1
  11. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/transport.py +4 -0
  12. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/logging/setup.py +27 -0
  13. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/memory/__init__.py +4 -1
  14. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/memory/logging.py +14 -1
  15. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/application/ports.py +11 -0
  16. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/composition/__init__.py +8 -5
  17. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/conftest.py +12 -6
  18. bitranox_template_py_cli-2.1.3/tests/test_cli_attachment_refused.py +191 -0
  19. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_cli_env_file.py +1 -0
  20. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_deploy_permissions.py +1 -0
  21. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_logging_dotenv_isolation.py +84 -2
  22. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_ports.py +14 -0
  23. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.devcontainer/devcontainer.json +0 -0
  24. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.devcontainer/settings.json +0 -0
  25. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.env.example +0 -0
  26. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.gitattributes +0 -0
  27. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.github/actions/extract-metadata/action.yml +0 -0
  28. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.github/dependabot.yml +0 -0
  29. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.github/workflows/codeql.yml +0 -0
  30. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.github/workflows/default_cicd_public.yml +0 -0
  31. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.github/workflows/default_release_public.yml +0 -0
  32. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.gitignore +0 -0
  33. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.qlty/qlty.toml +0 -0
  34. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/.snyk +0 -0
  35. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/CONFIG.md +0 -0
  36. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/CONTRIBUTING.md +0 -0
  37. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/DEVELOPMENT.md +0 -0
  38. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/INSTALL.md +0 -0
  39. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/LICENSE +0 -0
  40. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/Makefile +0 -0
  41. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/README.md +0 -0
  42. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/SECURITY.md +0 -0
  43. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/ai-stance.md +0 -0
  44. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/ai-transparency.md +0 -0
  45. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/codecov.yml +0 -0
  46. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/docs/adr/0001-memory-adapters-in-src.md +0 -0
  47. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/notebooks/Quickstart.ipynb +0 -0
  48. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/rename.sh +0 -0
  49. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/rename_dry.sh +0 -0
  50. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/reset_git_history.sh +0 -0
  51. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/__init__.py +0 -0
  52. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/__main__.py +0 -0
  53. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
  54. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
  55. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
  56. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/email/__init__.py +0 -0
  57. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_email.py +0 -0
  58. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_notification.py +0 -0
  59. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
  60. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
  61. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
  62. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
  63. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
  64. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
  65. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
  66. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/safe_console.py +0 -0
  67. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/typed_click.py +0 -0
  68. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
  69. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/40-layered-config.toml +0 -0
  70. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/90-logging.toml +0 -0
  71. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
  72. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
  73. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
  74. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
  75. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/overrides.py +0 -0
  76. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
  77. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
  78. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/config.py +0 -0
  79. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
  80. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
  81. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
  82. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
  83. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
  84. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
  85. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/memory/email.py +0 -0
  86. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
  87. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/application/__init__.py +0 -0
  88. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/application/py.typed +0 -0
  89. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/composition/py.typed +0 -0
  90. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
  91. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
  92. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/domain/enums.py +0 -0
  93. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/domain/errors.py +0 -0
  94. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/domain/py.typed +0 -0
  95. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/entry.py +0 -0
  96. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/py.typed +0 -0
  97. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_behaviors.py +0 -0
  98. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_cache_effectiveness.py +0 -0
  99. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_cli_config.py +0 -0
  100. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_cli_config_errors.py +0 -0
  101. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_cli_core.py +0 -0
  102. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_cli_email.py +0 -0
  103. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_cli_email_config_errors.py +0 -0
  104. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_cli_exit_codes.py +0 -0
  105. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_cli_main_exit.py +0 -0
  106. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_cli_overrides.py +0 -0
  107. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_cli_validation.py +0 -0
  108. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_config_overrides.py +0 -0
  109. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_declared_dependencies.py +0 -0
  110. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_deploy_mode_safety.py +0 -0
  111. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_display.py +0 -0
  112. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_email_attachment_lists.py +0 -0
  113. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_email_config_translation.py +0 -0
  114. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_email_password_secrecy.py +0 -0
  115. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_email_shipped_defaults.py +0 -0
  116. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_enums.py +0 -0
  117. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_errors.py +0 -0
  118. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_logging.py +0 -0
  119. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_mail.py +0 -0
  120. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_memory_logging.py +0 -0
  121. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_metadata.py +0 -0
  122. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_metadata_sync.py +0 -0
  123. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_module_entry.py +0 -0
  124. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_module_reference_sync.py +0 -0
  125. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_permission_defaults.py +0 -0
  126. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_property_email.py +0 -0
  127. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_property_overrides.py +0 -0
  128. {bitranox_template_py_cli-2.1.1 → bitranox_template_py_cli-2.1.3}/tests/test_safe_console.py +0 -0
@@ -6,6 +6,35 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.1.3] 2026-10-08 11:57:40
10
+
11
+ ### Fixed
12
+ - **`config --profile NAME` refuses an invalid logging setting like `config` does.** The
13
+ subcommand's `--profile` reloads the configuration after logging has started, and that reload
14
+ skipped the logging settings, so a `[lib_log_rich]` value or `LOG_*` variable that makes plain
15
+ `config` exit 78 was displayed with exit 0. The reload now restarts logging with the reloaded
16
+ configuration, which refuses exactly what plain `config` refuses (a scrub pattern, a console
17
+ format preset or style key and a level name included) with the same `Error:` lines, also for a
18
+ `[lib_log_rich]` section that is invalid only in that profile's own file; the rest of the
19
+ command then logs with the profile's settings. New `RestartLogging` port
20
+ (`AppServices.restart_logging`; the testing composition's double keeps its quiet runtime).
21
+
22
+ ## [2.1.2] 2026-10-08 09:43:32
23
+
24
+ ### Changed
25
+ - **A refused attachment exits 77, not 1 with a traceback (exit code change).** An attachment
26
+ btx_lib_mail's security checks refuse (a blocked extension such as `.exe`, a file under a
27
+ blocked directory, a symlink, a file over the size limit) made `send-email` exit 1 with
28
+ `Error: Unexpected error - Attachment security violation (...)` and log the refusal with a
29
+ traceback, as if the program had crashed. Since btx_lib_mail 4.0.0 every blocked extension is
30
+ refused on every platform, so this is an ordinary outcome on Linux and macOS too. It now exits
31
+ 77 (`ExitCode.ATTACHMENT_REFUSED`, sysexits `EX_NOPERM`: not permitted by policy) with one
32
+ line, `Error: Attachment refused by security policy - <the library's reason>`, logs no
33
+ traceback, and delivers nothing. A script that checks for 1 after a refused attachment must
34
+ check for 77. With `email.attachments.raise_on_security_violation = false` the attachment is
35
+ still skipped with a warning and the message sent, as before; a violation that names no attachment
36
+ left to drop is refused in warn mode too, and now exits 77 where it exited 1.
37
+
9
38
  ## [2.1.1] 2026-10-06 18:13:05
10
39
 
11
40
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bitranox_template_py_cli
3
- Version: 2.1.1
3
+ Version: 2.1.3
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 @@ 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
28
28
  Requires-Dist: lib-log-rich>=6.3.9
29
- Requires-Dist: orjson>=3.12.0
29
+ Requires-Dist: orjson>=3.13.0
30
30
  Requires-Dist: pydantic>=2.13.5
31
31
  Requires-Dist: python-dotenv>=1.2.4
32
32
  Requires-Dist: rich-click>=1.9.9
@@ -26,14 +26,14 @@ 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; 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)
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); `restart_logging` shuts the running runtime down and starts it again with another configuration
30
30
  - `src/bitranox_template_py_cli/adapters/cli/` - CLI adapter package:
31
31
  - `__init__.py` - Public facade
32
32
  - `constants.py` - Shared constants
33
33
  - `safe_console.py` - Encode-safe terminal output; use `safe_console.echo` instead of `click.echo`
34
34
  - `exit_codes.py` - POSIX exit codes (ExitCode IntEnum)
35
35
  - `context.py` - Click context helpers
36
- - `config_load.py` - Configuration load for the CLI; records a load failure, `require_config` refuses with exit 78
36
+ - `config_load.py` - Configuration load for the CLI; records a load failure, `require_config` refuses with exit 78, `restart_logging` restarts logging with a configuration reloaded by `config --profile`, refusing its logging settings as the root does
37
37
  - `root.py` - Root command group
38
38
  - `main.py` - Entry point
39
39
  - `typed_click.py` - Strictly typed wrappers for rich_click's `option`, `version_option` and `get_current_context`
@@ -70,6 +70,7 @@ moves or removes a module, a CLI option or a public name.
70
70
  - `tests/conftest.py` - Shared fixtures (see Test Fixtures below)
71
71
  - `tests/test_behaviors.py` - Domain function tests
72
72
  - `tests/test_cache_effectiveness.py` - Configuration loading returns consistent (cached) results
73
+ - `tests/test_cli_attachment_refused.py` - An attachment btx_lib_mail's security checks refuse exits 77 with one `Error:` line, nothing delivered
73
74
  - `tests/test_cli_config.py` - `config`, `config-deploy`, `config-generate-examples` commands
74
75
  - `tests/test_cli_config_errors.py` - A configuration that cannot be loaded: who reports it, and who still runs
75
76
  - `tests/test_cli_core.py` - Traceback, main entry, help, hello, fail, info, unknown command
@@ -136,19 +137,20 @@ Run `lint-imports` to verify compliance.
136
137
 
137
138
  POSIX-conventional exit codes defined in `adapters/cli/exit_codes.py`:
138
139
 
139
- | Code | Name | Usage |
140
- |------|---------------------|------------------------------------------------------------------------------------------------------------------------|
141
- | 0 | `SUCCESS` | Command completed successfully |
142
- | 1 | `GENERAL_ERROR` | Unhandled exception, general failure |
143
- | 2 | `FILE_NOT_FOUND` | Attachment or file not found; also click's usage error (bad option value, malformed `--set`, invalid `--profile` name) |
144
- | 13 | `PERMISSION_DENIED` | Cannot write to target directory |
145
- | 22 | `INVALID_ARGUMENT` | Invalid CLI argument or section not found |
146
- | 69 | `SMTP_FAILURE` | SMTP delivery failed |
147
- | 78 | `CONFIG_ERROR` | Configuration missing, not loadable or invalid |
148
- | 110 | `TIMEOUT` | Operation timed out |
149
- | 130 | `SIGNAL_INT` | Interrupted (SIGINT/Ctrl+C) |
150
- | 141 | `BROKEN_PIPE` | Output pipe closed |
151
- | 143 | `SIGNAL_TERM` | Terminated (SIGTERM) |
140
+ | Code | Name | Usage |
141
+ |------|----------------------|------------------------------------------------------------------------------------------------------------------------|
142
+ | 0 | `SUCCESS` | Command completed successfully |
143
+ | 1 | `GENERAL_ERROR` | Unhandled exception, general failure |
144
+ | 2 | `FILE_NOT_FOUND` | Attachment or file not found; also click's usage error (bad option value, malformed `--set`, invalid `--profile` name) |
145
+ | 13 | `PERMISSION_DENIED` | Cannot write to target directory |
146
+ | 22 | `INVALID_ARGUMENT` | Invalid CLI argument or section not found |
147
+ | 69 | `SMTP_FAILURE` | SMTP delivery failed |
148
+ | 77 | `ATTACHMENT_REFUSED` | Attachment refused by btx_lib_mail's security checks (blocked extension or directory, symlink, size, ...) |
149
+ | 78 | `CONFIG_ERROR` | Configuration missing, not loadable or invalid |
150
+ | 110 | `TIMEOUT` | Operation timed out |
151
+ | 130 | `SIGNAL_INT` | Interrupted (SIGINT/Ctrl+C) |
152
+ | 141 | `BROKEN_PIPE` | Output pipe closed |
153
+ | 143 | `SIGNAL_TERM` | Terminated (SIGTERM) |
152
154
 
153
155
  ---
154
156
 
@@ -243,7 +245,7 @@ Send email using configured SMTP settings.
243
245
  | `--raise-on-missing-attachments / --no-raise-on-missing-attachments` | Override missing-attachment handling |
244
246
  | `--raise-on-invalid-recipient / --no-raise-on-invalid-recipient` | Override invalid-recipient handling |
245
247
 
246
- **Exit codes:** 0, 2 (file not found, or usage error), 22 (invalid option value), 69 (SMTP failure), 78 (no SMTP hosts, an invalid `[email]` section, or configuration not loadable)
248
+ **Exit codes:** 0, 2 (file not found, or usage error), 22 (invalid option value), 69 (SMTP failure), 77 (an attachment refused by btx_lib_mail's security checks), 78 (no SMTP hosts, an invalid `[email]` section, or configuration not loadable)
247
249
 
248
250
  ### send-notification
249
251
 
@@ -411,7 +413,7 @@ The `adapters/memory/` package provides lightweight implementations for testing:
411
413
  |---------------------|-----------------------------------------------------------------------------|
412
414
  | `memory/config.py` | `GetConfig`, `GetDefaultConfigPath`, `DeployConfiguration`, `DisplayConfig` |
413
415
  | `memory/email.py` | `SendEmail`, `SendNotification` |
414
- | `memory/logging.py` | `InitLogging` |
416
+ | `memory/logging.py` | `InitLogging`, `RestartLogging` |
415
417
 
416
418
  `adapters/memory` exports `EmailSpy` (from `memory/email.py`): its `send_email` and
417
419
  `send_notification` methods satisfy the email ports and record each call as a `CapturedEmail` /
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bitranox_template_py_cli"
3
- version = "2.1.1"
3
+ version = "2.1.3"
4
4
  description = "Template CLI application with configuration management and structured logging"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -14,7 +14,7 @@ dependencies = [
14
14
  "lib_layered_config>=7.0.1",
15
15
  "btx_lib_mail>=4.0.0",
16
16
  "pydantic>=2.13.5",
17
- "orjson>=3.12.0",
17
+ "orjson>=3.13.0",
18
18
  ]
19
19
  license = { text = "MIT" }
20
20
  authors = [{ name = "bitranox", email = "bitranox@gmail.com" }]
@@ -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.1"
43
+ version = "2.1.3"
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.
@@ -32,7 +32,7 @@ from bitranox_template_py_cli.adapters.config.overrides import nest_overrides
32
32
  from bitranox_template_py_cli.domain.enums import DeployTarget, OutputFormat
33
33
 
34
34
  from .. import safe_console
35
- from ..config_load import load_config, report_load_failure, require_config
35
+ from ..config_load import load_config, report_load_failure, require_config, restart_logging
36
36
  from ..constants import CLICK_CONTEXT_SETTINGS
37
37
  from ..context import CLIContext, get_cli_context
38
38
  from ..exit_codes import ExitCode
@@ -110,7 +110,8 @@ def _resolve_config(ctx: click.Context, cli_ctx: CLIContext, profile: str | None
110
110
 
111
111
  When a subcommand-level profile override is specified, reloads config
112
112
  with that profile, the root's ``--env-file`` and any root-level ``--set``
113
- overrides stored in the CLI context.
113
+ overrides stored in the CLI context, and restarts logging with it, which
114
+ refuses its logging settings the way the root refuses its own.
114
115
 
115
116
  Args:
116
117
  ctx: The running command's click context, exited with 78 when loading failed.
@@ -126,6 +127,8 @@ def _resolve_config(ctx: click.Context, cli_ctx: CLIContext, profile: str | None
126
127
  config, error = load_config(
127
128
  cli_ctx.services, profile=profile, env_file=cli_ctx.env_file, set_overrides=cli_ctx.set_overrides
128
129
  )
130
+ if error is None:
131
+ error = restart_logging(cli_ctx.services, config, env_file=cli_ctx.env_file)
129
132
  if error is not None:
130
133
  report_load_failure(error, show_traceback=cli_ctx.traceback)
131
134
  ctx.exit(ExitCode.CONFIG_ERROR)
@@ -11,6 +11,7 @@ import logging
11
11
  import os
12
12
  from typing import TYPE_CHECKING, Any, NoReturn, cast
13
13
 
14
+ from btx_lib_mail import AttachmentSecurityError
14
15
  from pydantic import ValidationError
15
16
 
16
17
  from bitranox_template_py_cli import __init__conf__
@@ -169,8 +170,10 @@ def execute_with_email_error_handling(
169
170
  1. ConfigurationError -> CONFIG_ERROR (78): Missing/invalid config
170
171
  2. ValueError -> INVALID_ARGUMENT (22): Invalid parameters or email format
171
172
  3. FileNotFoundError -> FILE_NOT_FOUND (2): Missing attachment (if enabled)
172
- 4. DeliveryError/RuntimeError -> SMTP_FAILURE (69): SMTP transport failures
173
- 5. Exception (catch-all) -> GENERAL_ERROR (1): Unexpected errors with traceback
173
+ 4. AttachmentSecurityError -> ATTACHMENT_REFUSED (77): An attachment btx_lib_mail's
174
+ security checks refuse (blocked extension or directory, symlink, size, ...)
175
+ 5. DeliveryError/RuntimeError -> SMTP_FAILURE (69): SMTP transport failures
176
+ 6. Exception (catch-all) -> GENERAL_ERROR (1): Unexpected errors with traceback
174
177
 
175
178
  This ordering ensures specific exceptions aren't caught by broader handlers.
176
179
  When adding new exception types, insert them before the catch-all Exception handler.
@@ -205,6 +208,8 @@ def execute_with_email_error_handling(
205
208
  "Attachment file not found",
206
209
  exit_code=ExitCode.FILE_NOT_FOUND,
207
210
  )
211
+ except AttachmentSecurityError as exc:
212
+ _refuse_attachment(exc)
208
213
  except (DeliveryError, RuntimeError) as exc:
209
214
  _handle_send_error(
210
215
  exc,
@@ -264,6 +269,27 @@ def _refuse_email_config(exc: ValidationError, heading: str, *, exit_code: ExitC
264
269
  get_current_context().exit(exit_code)
265
270
 
266
271
 
272
+ def _refuse_attachment(exc: AttachmentSecurityError) -> NoReturn:
273
+ """Report an attachment btx_lib_mail refused on security grounds, then exit 77.
274
+
275
+ The refusal is policy working as configured, not a crash, so no traceback is logged. The
276
+ user sees the library's reason, which names the violation and the resolved path; it never
277
+ carries a credential.
278
+
279
+ Args:
280
+ exc: The refusal btx_lib_mail raised.
281
+
282
+ Raises:
283
+ click.exceptions.Exit: Always raised, with the ATTACHMENT_REFUSED exit code.
284
+ """
285
+ logger.error(
286
+ "Attachment refused by security policy",
287
+ extra={"reason": exc.reason, "violation_type": exc.violation_type.value},
288
+ )
289
+ safe_console.echo(f"\nError: Attachment refused by security policy - {exc.reason}", err=True)
290
+ get_current_context().exit(ExitCode.ATTACHMENT_REFUSED)
291
+
292
+
267
293
  def _handle_send_result(result: bool, recipients: list[str] | None, message_type: str) -> None:
268
294
  """Handle the result of a send operation.
269
295
 
@@ -16,6 +16,7 @@ Contents:
16
16
  * :func:`load_config` - load with profile, ``.env`` and ``--set``, or say why not.
17
17
  * :func:`start_logging` - start logging; an invalid ``[lib_log_rich]`` value or ``LOG_*``
18
18
  variable is a load failure.
19
+ * :func:`restart_logging` - the same refusal for a configuration loaded after logging started.
19
20
  * :func:`require_config` - the configuration, or exit 78 naming the failure.
20
21
  * :func:`report_load_failure` - the one-line report, after the traceback on request.
21
22
  * :func:`echo_load_traceback` - the loader's traceback alone, for a caller with its own line.
@@ -123,6 +124,30 @@ def start_logging(
123
124
  return config, config_error
124
125
 
125
126
 
127
+ def restart_logging(services: AppServices, config: Config, *, env_file: str | None) -> Exception | None:
128
+ """Restart logging with a configuration loaded after it started; a refused setting is returned.
129
+
130
+ ``config --profile`` reloads the configuration once logging is running, so
131
+ :func:`start_logging` never sees the reloaded ``[lib_log_rich]`` section; without this an
132
+ invalid value there would display with exit 0 while plain ``config`` exits 78. Restarting
133
+ runs the very start :func:`start_logging` ran, so it refuses the same settings in the same
134
+ words, and the rest of the command logs with the reloaded settings.
135
+
136
+ Args:
137
+ services: The composition's services; only ``restart_logging`` is used.
138
+ config: The configuration :func:`load_config` returned.
139
+ env_file: The ``--env-file`` path, or None; logging reads its ``LOG_*`` lines from it.
140
+
141
+ Returns:
142
+ None, or the refusal; logging then runs with its defaults.
143
+ """
144
+ try:
145
+ services.restart_logging(config, dotenv_path=env_file)
146
+ except InvalidLoggingConfigError as exc:
147
+ return exc
148
+ return None
149
+
150
+
126
151
  def echo_load_traceback(error: Exception, *, show_traceback: bool) -> None:
127
152
  """Write the loader's chained traceback to stderr when ``--traceback`` was given.
128
153
 
@@ -170,4 +195,11 @@ def require_config(ctx: click.Context, cli_ctx: CLIContext) -> Config:
170
195
  return cli_ctx.config
171
196
 
172
197
 
173
- __all__ = ["echo_load_traceback", "load_config", "report_load_failure", "require_config", "start_logging"]
198
+ __all__ = [
199
+ "echo_load_traceback",
200
+ "load_config",
201
+ "report_load_failure",
202
+ "require_config",
203
+ "restart_logging",
204
+ "start_logging",
205
+ ]
@@ -27,6 +27,8 @@ class ExitCode(IntEnum):
27
27
  * 2-13: errno-derived codes (ENOENT, EACCES)
28
28
  * 22: EINVAL
29
29
  * 69: EX_UNAVAILABLE (sysexits.h)
30
+ * 77: EX_NOPERM (sysexits.h): refused by policy, such as an email attachment
31
+ btx_lib_mail's security checks reject
30
32
  * 78: EX_CONFIG (sysexits.h)
31
33
  * 110: ETIMEDOUT
32
34
  * 128+N: signal N (informational only)
@@ -44,6 +46,7 @@ class ExitCode(IntEnum):
44
46
  PERMISSION_DENIED = 13
45
47
  INVALID_ARGUMENT = 22
46
48
  SMTP_FAILURE = 69
49
+ ATTACHMENT_REFUSED = 77
47
50
  CONFIG_ERROR = 78
48
51
  TIMEOUT = 110
49
52
  SIGNAL_INT = 130
@@ -235,5 +235,6 @@ allow_symlinks = false
235
235
  # Default: true (raise AttachmentSecurityError)
236
236
  # Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__ATTACHMENTS__RAISE_ON_SECURITY_VIOLATION=true
237
237
  # .env: EMAIL__ATTACHMENTS__RAISE_ON_SECURITY_VIOLATION=true
238
- # Note: When false, violations log a warning and skip the attachment
238
+ # Note: When true, send-email refuses the whole message and exits 77 (EX_NOPERM)
239
+ # naming the violation; when false, violations log a warning and skip the attachment
239
240
  raise_on_security_violation = true
@@ -128,6 +128,10 @@ def send_email(
128
128
  ConfigurationError: No SMTP hosts configured.
129
129
  FileNotFoundError: Required attachment missing or unreadable and
130
130
  config.raise_on_missing_attachments is True.
131
+ AttachmentSecurityError: btx_lib_mail's security checks refused an
132
+ attachment (blocked extension or directory, symlink, size, ...)
133
+ and config.attachment_raise_on_security_violation is True.
134
+ Raised before anything is delivered.
131
135
  DeliveryError: All SMTP hosts failed for a recipient.
132
136
 
133
137
  Side Effects:
@@ -7,6 +7,7 @@ eliminating duplication between module entry (__main__.py) and console script
7
7
  Contents:
8
8
  * :func:`init_logging` - idempotent logging initialization from the ``[lib_log_rich]``
9
9
  section and the ``LOG_*`` variables (the environment, plus the ``LOG_*`` lines of a ``.env``).
10
+ * :func:`restart_logging` - stop the running runtime and start it again with another configuration.
10
11
  * :class:`InvalidLoggingConfigError` - the ``[lib_log_rich]`` section or a ``LOG_*`` variable
11
12
  cannot configure logging.
12
13
  * :func:`_build_runtime_config` - constructs RuntimeConfig from layered sources.
@@ -280,9 +281,35 @@ def init_logging(config: Config, *, dotenv_path: str | None = None) -> None:
280
281
  lib_log_rich.runtime.attach_std_logging()
281
282
 
282
283
 
284
+ def restart_logging(config: Config, *, dotenv_path: str | None = None) -> None:
285
+ """Stop the running lib_log_rich runtime, then start it again with ``config``.
286
+
287
+ :func:`init_logging` returns at once while a runtime is running, so a configuration loaded
288
+ after logging started (``config --profile``'s reload) would never have its logging settings
289
+ judged. This runs that same start again: a setting lib_log_rich refuses raises exactly as it
290
+ does there, and logging is left running with the defaults. lib_log_rich has no public call
291
+ that checks settings without starting the runtime.
292
+
293
+ Args:
294
+ config: Already-loaded layered configuration object.
295
+ dotenv_path: As for :func:`init_logging`.
296
+
297
+ Raises:
298
+ InvalidLoggingConfigError: As for :func:`init_logging`.
299
+
300
+ Side Effects:
301
+ Flushes and shuts down the running runtime first (its queue, Graylog and journald
302
+ adapters included); the new one carries ``config``'s logging settings.
303
+ """
304
+ if lib_log_rich.runtime.is_initialised():
305
+ lib_log_rich.runtime.shutdown()
306
+ init_logging(config, dotenv_path=dotenv_path)
307
+
308
+
283
309
  __all__ = [
284
310
  "InvalidLoggingConfigError",
285
311
  "LoggingConfigModel",
286
312
  "init_logging",
287
313
  "log_variables_hidden",
314
+ "restart_logging",
288
315
  ]
@@ -20,7 +20,7 @@ from .config import (
20
20
  get_default_config_path_in_memory,
21
21
  )
22
22
  from .email import EmailSpy
23
- from .logging import init_logging_in_memory
23
+ from .logging import init_logging_in_memory, restart_logging_in_memory
24
24
 
25
25
  # Static conformance assertions
26
26
  if TYPE_CHECKING:
@@ -28,11 +28,13 @@ if TYPE_CHECKING:
28
28
  GetConfig,
29
29
  GetDefaultConfigPath,
30
30
  InitLogging,
31
+ RestartLogging,
31
32
  )
32
33
 
33
34
  _assert_get_config: GetConfig = get_config_in_memory
34
35
  _assert_get_default_config_path: GetDefaultConfigPath = get_default_config_path_in_memory
35
36
  _assert_init_logging: InitLogging = init_logging_in_memory
37
+ _assert_restart_logging: RestartLogging = restart_logging_in_memory
36
38
 
37
39
  __all__ = [
38
40
  "EmailSpy",
@@ -41,4 +43,5 @@ __all__ = [
41
43
  "get_config_in_memory",
42
44
  "get_default_config_path_in_memory",
43
45
  "init_logging_in_memory",
46
+ "restart_logging_in_memory",
44
47
  ]
@@ -63,4 +63,17 @@ def init_logging_in_memory(config: Config, *, dotenv_path: str | None = None) ->
63
63
  )
64
64
 
65
65
 
66
- __all__ = ["init_logging_in_memory"]
66
+ def restart_logging_in_memory(config: Config, *, dotenv_path: str | None = None) -> None:
67
+ """Keep the test runtime: :func:`init_logging_in_memory` ignores the configuration, so a restart changes nothing.
68
+
69
+ Args:
70
+ config: Unused; the parameter exists to satisfy the ``RestartLogging`` protocol.
71
+ dotenv_path: Unused for the same reason.
72
+
73
+ Example:
74
+ >>> from lib_layered_config import Config
75
+ >>> restart_logging_in_memory(Config({"lib_log_rich": {"rate_limit": "100:60"}}, {}))
76
+ """
77
+
78
+
79
+ __all__ = ["init_logging_in_memory", "restart_logging_in_memory"]
@@ -109,6 +109,16 @@ class InitLogging(Protocol):
109
109
  def __call__(self, config: Config, *, dotenv_path: str | None = None) -> None: ...
110
110
 
111
111
 
112
+ class RestartLogging(Protocol):
113
+ """Stop the running logging runtime and start it again with ``config``, as ``InitLogging`` does.
114
+
115
+ For a configuration loaded after logging is running; a refused setting raises
116
+ ``InvalidLoggingConfigError`` after logging has been started with its defaults.
117
+ """
118
+
119
+ def __call__(self, config: Config, *, dotenv_path: str | None = None) -> None: ...
120
+
121
+
112
122
  __all__ = [
113
123
  "DeployConfiguration",
114
124
  "DisplayConfig",
@@ -116,6 +126,7 @@ __all__ = [
116
126
  "GetDefaultConfigPath",
117
127
  "InitLogging",
118
128
  "LoadEmailConfigFromDict",
129
+ "RestartLogging",
119
130
  "SendEmail",
120
131
  "SendNotification",
121
132
  ]
@@ -19,7 +19,7 @@ from ..adapters.email.sender import (
19
19
  )
20
20
 
21
21
  # Logging services
22
- from ..adapters.logging.setup import init_logging
22
+ from ..adapters.logging.setup import init_logging, restart_logging
23
23
 
24
24
  # Static conformance assertions — pyright verifies that each adapter function
25
25
  # structurally satisfies its corresponding Protocol at type-check time.
@@ -32,6 +32,7 @@ if TYPE_CHECKING:
32
32
  GetDefaultConfigPath,
33
33
  InitLogging,
34
34
  LoadEmailConfigFromDict,
35
+ RestartLogging,
35
36
  SendEmail,
36
37
  SendNotification,
37
38
  )
@@ -44,6 +45,7 @@ if TYPE_CHECKING:
44
45
  _assert_send_notification: SendNotification = send_notification
45
46
  _assert_load_email_config_from_dict: LoadEmailConfigFromDict = load_email_config_from_dict
46
47
  _assert_init_logging: InitLogging = init_logging
48
+ _assert_restart_logging: RestartLogging = restart_logging
47
49
 
48
50
 
49
51
  @dataclass(frozen=True, slots=True)
@@ -58,6 +60,7 @@ class AppServices:
58
60
  send_notification: SendNotification
59
61
  load_email_config_from_dict: LoadEmailConfigFromDict
60
62
  init_logging: InitLogging
63
+ restart_logging: RestartLogging
61
64
 
62
65
 
63
66
  def build_production() -> AppServices:
@@ -71,6 +74,7 @@ def build_production() -> AppServices:
71
74
  send_notification=send_notification,
72
75
  load_email_config_from_dict=load_email_config_from_dict,
73
76
  init_logging=init_logging,
77
+ restart_logging=restart_logging,
74
78
  )
75
79
 
76
80
 
@@ -92,6 +96,7 @@ def build_testing(*, spy: EmailSpy | None = None) -> AppServices:
92
96
  get_config_in_memory,
93
97
  get_default_config_path_in_memory,
94
98
  init_logging_in_memory,
99
+ restart_logging_in_memory,
95
100
  )
96
101
 
97
102
  email_spy = spy if spy is not None else EmailSpy()
@@ -106,23 +111,21 @@ def build_testing(*, spy: EmailSpy | None = None) -> AppServices:
106
111
  # The real translation: an in-memory one would let every CLI test skip it.
107
112
  load_email_config_from_dict=load_email_config_from_dict,
108
113
  init_logging=init_logging_in_memory,
114
+ restart_logging=restart_logging_in_memory,
109
115
  )
110
116
 
111
117
 
112
118
  __all__ = [
113
- # Composition
114
119
  "AppServices",
115
120
  "build_production",
116
121
  "build_testing",
117
122
  "deploy_configuration",
118
123
  "display_config",
119
- # Configuration
120
124
  "get_config",
121
125
  "get_default_config_path",
122
- # Logging
123
126
  "init_logging",
124
127
  "load_email_config_from_dict",
125
- # Email
128
+ "restart_logging",
126
129
  "send_email",
127
130
  "send_notification",
128
131
  ]
@@ -384,7 +384,7 @@ def inject_config(
384
384
  result = cli_runner.invoke(cli, ["config"], obj=factory)
385
385
  assert "key" in result.output
386
386
  """
387
- from bitranox_template_py_cli.adapters.memory import init_logging_in_memory
387
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
388
388
  from bitranox_template_py_cli.composition import AppServices, build_production
389
389
 
390
390
  def _inject(config: Config) -> Callable[[], AppServices]:
@@ -403,6 +403,7 @@ def inject_config(
403
403
  # The quiet runtime: production init_logging queues INFO lines that race into
404
404
  # CliRunner's stderr, so a stderr assertion would depend on timing.
405
405
  init_logging=init_logging_in_memory,
406
+ restart_logging=restart_logging_in_memory,
406
407
  )
407
408
  return lambda: test_services
408
409
 
@@ -438,7 +439,7 @@ def inject_config_with_profile_capture(
438
439
  cli_runner.invoke(cli, ["--profile", "staging", "config"], obj=factory)
439
440
  assert captured == ["staging"]
440
441
  """
441
- from bitranox_template_py_cli.adapters.memory import init_logging_in_memory
442
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
442
443
  from bitranox_template_py_cli.composition import AppServices, build_production
443
444
 
444
445
  def _inject(config: Config, captured_profiles: list[str | None]) -> Callable[[], AppServices]:
@@ -458,6 +459,7 @@ def inject_config_with_profile_capture(
458
459
  # The quiet runtime: production init_logging queues INFO lines that race into
459
460
  # CliRunner's stderr, so a stderr assertion would depend on timing.
460
461
  init_logging=init_logging_in_memory,
462
+ restart_logging=restart_logging_in_memory,
461
463
  )
462
464
  return lambda: test_services
463
465
 
@@ -493,7 +495,7 @@ def inject_deploy_with_profile_capture(
493
495
  cli_runner.invoke(cli, ["--profile", "prod", "config-deploy", ...], obj=factory)
494
496
  assert captured == ["prod"]
495
497
  """
496
- from bitranox_template_py_cli.adapters.memory import init_logging_in_memory
498
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
497
499
  from bitranox_template_py_cli.composition import AppServices, build_production
498
500
 
499
501
  def _inject(deployed_path: Path, captured_profiles: list[str | None]) -> Callable[[], AppServices]:
@@ -522,6 +524,7 @@ def inject_deploy_with_profile_capture(
522
524
  # The quiet runtime: production init_logging queues INFO lines that race into
523
525
  # CliRunner's stderr, so a stderr assertion would depend on timing.
524
526
  init_logging=init_logging_in_memory,
527
+ restart_logging=restart_logging_in_memory,
525
528
  )
526
529
  return lambda: test_services
527
530
 
@@ -553,7 +556,7 @@ def inject_deploy_configuration() -> Callable[[Callable[..., list[Path]]], Calla
553
556
  cli_runner.invoke(cli, ["config-deploy", "--target", "user"], obj=factory)
554
557
  assert len(calls) == 1
555
558
  """
556
- from bitranox_template_py_cli.adapters.memory import init_logging_in_memory
559
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
557
560
  from bitranox_template_py_cli.composition import AppServices, build_production
558
561
 
559
562
  def _inject(deploy_fn: Callable[..., list[Path]]) -> Callable[[], AppServices]:
@@ -569,6 +572,7 @@ def inject_deploy_configuration() -> Callable[[Callable[..., list[Path]]], Calla
569
572
  # The quiet runtime: production init_logging queues INFO lines that race into
570
573
  # CliRunner's stderr, so a stderr assertion would depend on timing.
571
574
  init_logging=init_logging_in_memory,
575
+ restart_logging=restart_logging_in_memory,
572
576
  )
573
577
  return lambda: test_services
574
578
 
@@ -654,7 +658,7 @@ def email_cli_context(
654
658
  assert ctx.spy.sent_notifications[0].subject == "Hi"
655
659
  """
656
660
  from bitranox_template_py_cli.adapters.email.config import load_email_config_from_dict
657
- from bitranox_template_py_cli.adapters.memory import init_logging_in_memory
661
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
658
662
  from bitranox_template_py_cli.adapters.memory.email import EmailSpy as EmailSpyImpl
659
663
  from bitranox_template_py_cli.composition import AppServices, build_production
660
664
 
@@ -677,6 +681,7 @@ def email_cli_context(
677
681
  # The quiet runtime: production init_logging queues INFO lines that race into
678
682
  # CliRunner's stderr, so a stderr assertion would depend on timing.
679
683
  init_logging=init_logging_in_memory,
684
+ restart_logging=restart_logging_in_memory,
680
685
  )
681
686
  return EmailCliContext(factory=lambda: test_services, spy=spy)
682
687
 
@@ -708,7 +713,7 @@ def config_cli_context(
708
713
  result = cli_runner.invoke(cli, ["config"], obj=factory)
709
714
  assert "key" in result.output
710
715
  """
711
- from bitranox_template_py_cli.adapters.memory import init_logging_in_memory
716
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
712
717
  from bitranox_template_py_cli.composition import AppServices, build_production
713
718
 
714
719
  def _create(config_data: dict[str, Any]) -> Callable[[], AppServices]:
@@ -729,6 +734,7 @@ def config_cli_context(
729
734
  # The quiet runtime: production init_logging queues INFO lines that race into
730
735
  # CliRunner's stderr, so a stderr assertion would depend on timing.
731
736
  init_logging=init_logging_in_memory,
737
+ restart_logging=restart_logging_in_memory,
732
738
  )
733
739
  return lambda: test_services
734
740