bitranox-template-py-cli 2.1.2__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.2 → bitranox_template_py_cli-2.1.3}/CHANGELOG.md +13 -0
  2. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/PKG-INFO +1 -1
  3. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/docs/systemdesign/module_reference.md +3 -3
  4. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/pyproject.toml +1 -1
  5. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
  6. {bitranox_template_py_cli-2.1.2 → 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.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/config_load.py +33 -1
  8. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/logging/setup.py +27 -0
  9. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/memory/__init__.py +4 -1
  10. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/memory/logging.py +14 -1
  11. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/application/ports.py +11 -0
  12. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/composition/__init__.py +8 -5
  13. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/conftest.py +12 -6
  14. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cli_env_file.py +1 -0
  15. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_deploy_permissions.py +1 -0
  16. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_logging_dotenv_isolation.py +84 -2
  17. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_ports.py +14 -0
  18. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.devcontainer/devcontainer.json +0 -0
  19. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.devcontainer/settings.json +0 -0
  20. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.env.example +0 -0
  21. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.gitattributes +0 -0
  22. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.github/actions/extract-metadata/action.yml +0 -0
  23. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.github/dependabot.yml +0 -0
  24. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.github/workflows/codeql.yml +0 -0
  25. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.github/workflows/default_cicd_public.yml +0 -0
  26. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.github/workflows/default_release_public.yml +0 -0
  27. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.gitignore +0 -0
  28. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.qlty/qlty.toml +0 -0
  29. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/.snyk +0 -0
  30. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/CONFIG.md +0 -0
  31. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/CONTRIBUTING.md +0 -0
  32. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/DEVELOPMENT.md +0 -0
  33. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/INSTALL.md +0 -0
  34. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/LICENSE +0 -0
  35. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/Makefile +0 -0
  36. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/README.md +0 -0
  37. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/SECURITY.md +0 -0
  38. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/ai-stance.md +0 -0
  39. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/ai-transparency.md +0 -0
  40. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/codecov.yml +0 -0
  41. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/docs/adr/0001-memory-adapters-in-src.md +0 -0
  42. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/notebooks/Quickstart.ipynb +0 -0
  43. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/rename.sh +0 -0
  44. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/rename_dry.sh +0 -0
  45. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/reset_git_history.sh +0 -0
  46. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/__init__.py +0 -0
  47. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/__main__.py +0 -0
  48. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
  49. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
  50. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
  51. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/email/__init__.py +0 -0
  52. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/email/_common.py +0 -0
  53. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_email.py +0 -0
  54. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_notification.py +0 -0
  55. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
  56. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
  57. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
  58. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
  59. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +0 -0
  60. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
  61. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
  62. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
  63. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/safe_console.py +0 -0
  64. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/cli/typed_click.py +0 -0
  65. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
  66. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/40-layered-config.toml +0 -0
  67. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +0 -0
  68. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/90-logging.toml +0 -0
  69. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
  70. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
  71. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
  72. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
  73. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/overrides.py +0 -0
  74. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
  75. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
  76. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/config.py +0 -0
  77. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
  78. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
  79. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/transport.py +0 -0
  80. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
  81. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
  82. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
  83. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
  84. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/memory/email.py +0 -0
  85. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
  86. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/application/__init__.py +0 -0
  87. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/application/py.typed +0 -0
  88. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/composition/py.typed +0 -0
  89. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
  90. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
  91. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/domain/enums.py +0 -0
  92. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/domain/errors.py +0 -0
  93. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/domain/py.typed +0 -0
  94. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/entry.py +0 -0
  95. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/src/bitranox_template_py_cli/py.typed +0 -0
  96. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_behaviors.py +0 -0
  97. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cache_effectiveness.py +0 -0
  98. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cli_attachment_refused.py +0 -0
  99. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cli_config.py +0 -0
  100. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cli_config_errors.py +0 -0
  101. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cli_core.py +0 -0
  102. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cli_email.py +0 -0
  103. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cli_email_config_errors.py +0 -0
  104. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cli_exit_codes.py +0 -0
  105. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cli_main_exit.py +0 -0
  106. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cli_overrides.py +0 -0
  107. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_cli_validation.py +0 -0
  108. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_config_overrides.py +0 -0
  109. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_declared_dependencies.py +0 -0
  110. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_deploy_mode_safety.py +0 -0
  111. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_display.py +0 -0
  112. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_email_attachment_lists.py +0 -0
  113. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_email_config_translation.py +0 -0
  114. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_email_password_secrecy.py +0 -0
  115. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_email_shipped_defaults.py +0 -0
  116. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_enums.py +0 -0
  117. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_errors.py +0 -0
  118. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_logging.py +0 -0
  119. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_mail.py +0 -0
  120. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_memory_logging.py +0 -0
  121. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_metadata.py +0 -0
  122. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_metadata_sync.py +0 -0
  123. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_module_entry.py +0 -0
  124. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_module_reference_sync.py +0 -0
  125. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_permission_defaults.py +0 -0
  126. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_property_email.py +0 -0
  127. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_property_overrides.py +0 -0
  128. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.1.3}/tests/test_safe_console.py +0 -0
@@ -6,6 +6,19 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.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
+
9
22
  ## [2.1.2] 2026-10-08 09:43:32
10
23
 
11
24
  ### Changed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bitranox_template_py_cli
3
- Version: 2.1.2
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,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`
@@ -413,7 +413,7 @@ The `adapters/memory/` package provides lightweight implementations for testing:
413
413
  |---------------------|-----------------------------------------------------------------------------|
414
414
  | `memory/config.py` | `GetConfig`, `GetDefaultConfigPath`, `DeployConfiguration`, `DisplayConfig` |
415
415
  | `memory/email.py` | `SendEmail`, `SendNotification` |
416
- | `memory/logging.py` | `InitLogging` |
416
+ | `memory/logging.py` | `InitLogging`, `RestartLogging` |
417
417
 
418
418
  `adapters/memory` exports `EmailSpy` (from `memory/email.py`): its `send_email` and
419
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.2"
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"
@@ -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.2"
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)
@@ -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
+ ]
@@ -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
 
@@ -52,6 +52,7 @@ def inject_config_with_dotenv_capture(
52
52
  send_notification=prod.send_notification,
53
53
  load_email_config_from_dict=prod.load_email_config_from_dict,
54
54
  init_logging=prod.init_logging,
55
+ restart_logging=prod.restart_logging,
55
56
  )
56
57
  return lambda: test_services
57
58
 
@@ -77,6 +77,7 @@ def inject_deploy_with_permission_capture(
77
77
  send_notification=prod.send_notification,
78
78
  load_email_config_from_dict=prod.load_email_config_from_dict,
79
79
  init_logging=prod.init_logging,
80
+ restart_logging=prod.restart_logging,
80
81
  )
81
82
  return lambda: test_services
82
83
 
@@ -15,9 +15,11 @@ The end-to-end tests run the real CLI in a subprocess with every configuration l
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import logging
18
19
  import os
19
20
  import subprocess
20
21
  import sys
22
+ from pathlib import Path
21
23
  from typing import TYPE_CHECKING
22
24
 
23
25
  import lib_log_rich.runtime
@@ -25,11 +27,14 @@ import pytest
25
27
  from lib_layered_config import Config
26
28
 
27
29
  from bitranox_template_py_cli import __init__conf__
28
- from bitranox_template_py_cli.adapters.logging.setup import InvalidLoggingConfigError, init_logging
30
+ from bitranox_template_py_cli.adapters.logging.setup import (
31
+ InvalidLoggingConfigError,
32
+ init_logging,
33
+ restart_logging,
34
+ )
29
35
 
30
36
  if TYPE_CHECKING:
31
37
  from collections.abc import Iterator
32
- from pathlib import Path
33
38
 
34
39
  PREFIX = __init__conf__.LAYEREDCONF_SLUG.upper().replace("-", "_")
35
40
  _HOMES = ("HOME", "USERPROFILE", "XDG_CONFIG_HOME", "APPDATA", "LOCALAPPDATA")
@@ -206,6 +211,83 @@ def test_an_invalid_logging_section_does_not_block_the_deploy_that_replaces_it(t
206
211
  assert result.returncode == 0, result.stderr
207
212
 
208
213
 
214
+ # ------------------------------------------------------------------ config --profile reloads, and checks logging too
215
+
216
+ #: One refused setting of each kind lib_log_rich has: the section's type check, its own range
217
+ #: check, a ``LOG_*`` value its settings resolution refuses, and the values only starting the
218
+ #: runtime refuses - a level name (in the section or a ``LOG_*`` variable), a scrub pattern, a
219
+ #: console format preset and a console style key.
220
+ _REFUSED_LOGGING = {
221
+ "type": {f"{PREFIX}___LIB_LOG_RICH__RATE_LIMIT": "100:60"},
222
+ "range": {f"{PREFIX}___LIB_LOG_RICH__QUEUE_MAXSIZE": "0"},
223
+ "settings-variable": {"LOG_RATE_LIMIT": "bogus"},
224
+ "section-level": {f"{PREFIX}___LIB_LOG_RICH__CONSOLE_LEVEL": "bogus"},
225
+ "log-variable": {"LOG_CONSOLE_LEVEL": "bogus"},
226
+ "scrub-pattern": {"LOG_SCRUB_PATTERNS": "token=("},
227
+ "console-preset": {"LOG_CONSOLE_FORMAT_PRESET": "bogus"},
228
+ "console-style": {"LOG_CONSOLE_STYLES": "bogus=red"},
229
+ }
230
+
231
+
232
+ def _error_lines(result: subprocess.CompletedProcess[str]) -> list[str]:
233
+ return [line for line in result.stderr.splitlines() if line.startswith("Error:")]
234
+
235
+
236
+ @pytest.mark.os_agnostic
237
+ @pytest.mark.parametrize("env", _REFUSED_LOGGING.values(), ids=_REFUSED_LOGGING.keys())
238
+ def test_config_with_a_profile_refuses_a_logging_setting_like_config_without_one(
239
+ tmp_path: Path, env: dict[str, str]
240
+ ) -> None:
241
+ plain = _run(tmp_path, "config", env=env)
242
+ reloaded = _run(tmp_path, "config", "--profile", "production", env=env)
243
+
244
+ # Premise: plain config refuses this setting with one line per problem.
245
+ assert plain.returncode == 78, plain.stderr
246
+ assert _error_lines(plain), plain.stderr
247
+ assert reloaded.returncode == 78, reloaded.stderr
248
+ assert _error_lines(reloaded) == _error_lines(plain)
249
+
250
+
251
+ @pytest.mark.os_agnostic
252
+ def test_a_logging_section_refused_only_in_the_profile_file_refuses_config_with_that_profile(tmp_path: Path) -> None:
253
+ # The user layer lives in a different place on each OS; the deploy writes it there and says where.
254
+ deployed = _run(tmp_path, "config-deploy", "--target", "user", "--profile", "broken")
255
+ assert deployed.returncode == 0, deployed.stderr
256
+ written = [line.strip()[2:] for line in deployed.stdout.splitlines() if line.strip().startswith("+ ")]
257
+ profile_files = [Path(path) for path in written if Path(path).name == "config.toml"]
258
+ assert len(profile_files) == 1, deployed.stdout
259
+ # config.d merges after config.toml, and the deployed 90-logging.toml sets rate_limit itself.
260
+ (profile_files[0].parent / "config.d" / "99-refused.toml").write_text(
261
+ '[lib_log_rich]\nrate_limit = "100:60"\n', encoding="utf-8"
262
+ )
263
+
264
+ plain = _run(tmp_path, "config")
265
+ reloaded = _run(tmp_path, "config", "--profile", "broken")
266
+
267
+ assert plain.returncode == 0, plain.stderr
268
+ assert reloaded.returncode == 78, reloaded.stderr
269
+ errors = _error_lines(reloaded)
270
+ assert len(errors) == 1, reloaded.stderr
271
+ assert "lib_log_rich.rate_limit" in errors[0]
272
+ assert "100:60" not in reloaded.stderr
273
+
274
+
275
+ @pytest.mark.os_agnostic
276
+ def test_restarting_logging_refuses_a_setting_although_logging_already_runs(monkeypatch: pytest.MonkeyPatch) -> None:
277
+ """``init_logging`` returns at once on a running runtime; a restart must judge the new settings."""
278
+ init_logging(Config({}, {}))
279
+ # Premise: logging runs, so a plain init_logging would no longer check anything.
280
+ assert lib_log_rich.runtime.is_initialised()
281
+ monkeypatch.setenv("LOG_SCRUB_PATTERNS", "token=(")
282
+
283
+ with pytest.raises(InvalidLoggingConfigError, match="Invalid scrub pattern"):
284
+ restart_logging(Config({}, {}))
285
+
286
+ assert lib_log_rich.runtime.is_initialised()
287
+ handlers = [h for h in logging.getLogger().handlers if isinstance(h, lib_log_rich.runtime.StdlibLoggingHandler)]
288
+ assert len(handlers) == 1
289
+
290
+
209
291
  # ------------------------------------------------------------------ a refused setting still leaves logging running
210
292
 
211
293
 
@@ -19,6 +19,7 @@ from bitranox_template_py_cli.adapters.memory import (
19
19
  get_config_in_memory,
20
20
  get_default_config_path_in_memory,
21
21
  init_logging_in_memory,
22
+ restart_logging_in_memory,
22
23
  )
23
24
 
24
25
  if TYPE_CHECKING:
@@ -27,6 +28,7 @@ if TYPE_CHECKING:
27
28
  GetDefaultConfigPath,
28
29
  InitLogging,
29
30
  LoadEmailConfigFromDict,
31
+ RestartLogging,
30
32
  SendEmail,
31
33
  SendNotification,
32
34
  )
@@ -73,6 +75,12 @@ def init_logging_impl() -> InitLogging:
73
75
  return init_logging_in_memory
74
76
 
75
77
 
78
+ @pytest.fixture
79
+ def restart_logging_impl() -> RestartLogging:
80
+ """Provide in-memory RestartLogging implementation."""
81
+ return restart_logging_in_memory
82
+
83
+
76
84
  @pytest.mark.os_agnostic
77
85
  def test_get_config_returns_config_with_dict(get_config_impl: GetConfig) -> None:
78
86
  """GetConfig must return a Config whose as_dict() yields a dict."""
@@ -143,6 +151,12 @@ def test_init_logging_does_not_raise(init_logging_impl: InitLogging) -> None:
143
151
  init_logging_impl(config)
144
152
 
145
153
 
154
+ @pytest.mark.os_agnostic
155
+ def test_restart_logging_in_memory_refuses_nothing(restart_logging_impl: RestartLogging) -> None:
156
+ """The test runtime ignores the configuration, so restarting it refuses no logging section either."""
157
+ restart_logging_impl(Config({"lib_log_rich": {"rate_limit": "100:60"}}, {}))
158
+
159
+
146
160
  # ======================== Composition Wiring Tests ========================
147
161
 
148
162