bitranox-template-py-cli 2.1.2__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/CHANGELOG.md +26 -0
  2. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/PKG-INFO +1 -1
  3. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/docs/systemdesign/module_reference.md +26 -29
  4. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/pyproject.toml +1 -1
  5. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
  6. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/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.2.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/_common.py +4 -8
  8. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_email.py +5 -0
  9. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_notification.py +0 -2
  10. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/config_load.py +33 -1
  11. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/logging/setup.py +27 -0
  12. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/memory/__init__.py +4 -1
  13. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/memory/logging.py +14 -1
  14. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/application/ports.py +11 -0
  15. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/composition/__init__.py +8 -5
  16. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/conftest.py +24 -6
  17. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_config.py +22 -1
  18. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_email.py +39 -0
  19. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_env_file.py +1 -0
  20. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_deploy_permissions.py +1 -0
  21. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_email_password_secrecy.py +2 -5
  22. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_logging_dotenv_isolation.py +84 -2
  23. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_permission_defaults.py +1 -3
  24. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_ports.py +14 -0
  25. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.devcontainer/devcontainer.json +0 -0
  26. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.devcontainer/settings.json +0 -0
  27. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.env.example +0 -0
  28. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.gitattributes +0 -0
  29. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.github/actions/extract-metadata/action.yml +0 -0
  30. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.github/dependabot.yml +0 -0
  31. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.github/workflows/codeql.yml +0 -0
  32. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.github/workflows/default_cicd_public.yml +0 -0
  33. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.github/workflows/default_release_public.yml +0 -0
  34. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.gitignore +0 -0
  35. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.qlty/qlty.toml +0 -0
  36. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.snyk +0 -0
  37. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/CONFIG.md +0 -0
  38. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/CONTRIBUTING.md +0 -0
  39. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/DEVELOPMENT.md +0 -0
  40. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/INSTALL.md +0 -0
  41. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/LICENSE +0 -0
  42. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/Makefile +0 -0
  43. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/README.md +0 -0
  44. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/SECURITY.md +0 -0
  45. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/ai-stance.md +0 -0
  46. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/ai-transparency.md +0 -0
  47. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/codecov.yml +0 -0
  48. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/docs/adr/0001-memory-adapters-in-src.md +0 -0
  49. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/notebooks/Quickstart.ipynb +0 -0
  50. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/rename.sh +0 -0
  51. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/rename_dry.sh +0 -0
  52. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/reset_git_history.sh +0 -0
  53. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/__init__.py +0 -0
  54. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/__main__.py +0 -0
  55. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
  56. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
  57. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
  58. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/commands/email/__init__.py +0 -0
  59. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
  60. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
  61. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
  62. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
  63. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +0 -0
  64. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
  65. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
  66. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
  67. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/safe_console.py +0 -0
  68. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/cli/typed_click.py +0 -0
  69. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
  70. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/40-layered-config.toml +0 -0
  71. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +0 -0
  72. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/90-logging.toml +0 -0
  73. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
  74. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
  75. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
  76. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
  77. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/config/overrides.py +0 -0
  78. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
  79. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
  80. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/email/config.py +0 -0
  81. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
  82. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
  83. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/email/transport.py +0 -0
  84. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
  85. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
  86. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
  87. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
  88. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/memory/email.py +0 -0
  89. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
  90. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/application/__init__.py +0 -0
  91. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/application/py.typed +0 -0
  92. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/composition/py.typed +0 -0
  93. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
  94. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
  95. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/domain/enums.py +0 -0
  96. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/domain/errors.py +0 -0
  97. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/domain/py.typed +0 -0
  98. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/entry.py +0 -0
  99. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/py.typed +0 -0
  100. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_behaviors.py +0 -0
  101. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cache_effectiveness.py +0 -0
  102. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_attachment_refused.py +0 -0
  103. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_config_errors.py +0 -0
  104. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_core.py +0 -0
  105. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_email_config_errors.py +0 -0
  106. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_exit_codes.py +0 -0
  107. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_main_exit.py +0 -0
  108. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_overrides.py +0 -0
  109. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_validation.py +0 -0
  110. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_config_overrides.py +0 -0
  111. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_declared_dependencies.py +0 -0
  112. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_deploy_mode_safety.py +0 -0
  113. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_display.py +0 -0
  114. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_email_attachment_lists.py +0 -0
  115. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_email_config_translation.py +0 -0
  116. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_email_shipped_defaults.py +0 -0
  117. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_enums.py +0 -0
  118. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_errors.py +0 -0
  119. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_logging.py +0 -0
  120. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_mail.py +0 -0
  121. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_memory_logging.py +0 -0
  122. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_metadata.py +0 -0
  123. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_metadata_sync.py +0 -0
  124. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_module_entry.py +0 -0
  125. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_module_reference_sync.py +0 -0
  126. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_property_email.py +0 -0
  127. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_property_overrides.py +0 -0
  128. {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_safe_console.py +0 -0
@@ -6,6 +6,32 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.2.0] 2026-10-08 13:56:49
10
+
11
+ ### Removed
12
+ - **`send-notification --raise-on-missing-attachments` (usage change).** A notification takes no
13
+ attachments, so the switch did nothing there. `send-notification` now refuses it as an unknown
14
+ option (exit 2); `send-email` keeps it. Drop the flag from any script that passes it to
15
+ `send-notification`.
16
+
17
+ ### Tests
18
+ - The `config-deploy --force` report for a deploy in which every target file is already identical
19
+ is covered end to end. New shared fixture `user_layer_in_tmp_path` points every OS's user-layer
20
+ location at `tmp_path`; it replaces two hand-written copies of the same loop.
21
+
22
+ ## [2.1.3] 2026-10-08 11:57:40
23
+
24
+ ### Fixed
25
+ - **`config --profile NAME` refuses an invalid logging setting like `config` does.** The
26
+ subcommand's `--profile` reloads the configuration after logging has started, and that reload
27
+ skipped the logging settings, so a `[lib_log_rich]` value or `LOG_*` variable that makes plain
28
+ `config` exit 78 was displayed with exit 0. The reload now restarts logging with the reloaded
29
+ configuration, which refuses exactly what plain `config` refuses (a scrub pattern, a console
30
+ format preset or style key and a level name included) with the same `Error:` lines, also for a
31
+ `[lib_log_rich]` section that is invalid only in that profile's own file; the rest of the
32
+ command then logs with the profile's settings. New `RestartLogging` port
33
+ (`AppServices.restart_logging`; the testing composition's double keeps its quiet runtime).
34
+
9
35
  ## [2.1.2] 2026-10-08 09:43:32
10
36
 
11
37
  ### 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.2.0
4
4
  Summary: Template CLI application with configuration management and structured logging
5
5
  Project-URL: Homepage, https://github.com/bitranox/bitranox_template_py_cli
6
6
  Project-URL: Repository, https://github.com/bitranox/bitranox_template_py_cli.git
@@ -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`
@@ -251,22 +251,18 @@ Send email using configured SMTP settings.
251
251
 
252
252
  Send simple plain-text notification email.
253
253
 
254
- | Option | Description |
255
- |----------------------------------------------------------------------|--------------------------------------|
256
- | `--to ADDRESS` | Recipient (repeatable) |
257
- | `--subject TEXT` | Subject - required |
258
- | `--message TEXT` | Message - required |
259
- | `--from ADDRESS` | Override sender |
260
- | `--smtp-host HOST:PORT` | Override SMTP host (repeatable) |
261
- | `--smtp-username USER` | Override username |
262
- | `--smtp-password PASS` | Override password |
263
- | `--use-starttls / --no-use-starttls` | Override STARTTLS |
264
- | `--timeout SECONDS` | Override timeout |
265
- | `--raise-on-missing-attachments / --no-raise-on-missing-attachments` | Override missing-attachment handling |
266
- | `--raise-on-invalid-recipient / --no-raise-on-invalid-recipient` | Override invalid-recipient handling |
267
-
268
- `--raise-on-missing-attachments` is accepted for symmetry with `send-email`, but a notification
269
- carries no attachments, so it has no effect.
254
+ | Option | Description |
255
+ |------------------------------------------------------------------|-------------------------------------|
256
+ | `--to ADDRESS` | Recipient (repeatable) |
257
+ | `--subject TEXT` | Subject - required |
258
+ | `--message TEXT` | Message - required |
259
+ | `--from ADDRESS` | Override sender |
260
+ | `--smtp-host HOST:PORT` | Override SMTP host (repeatable) |
261
+ | `--smtp-username USER` | Override username |
262
+ | `--smtp-password PASS` | Override password |
263
+ | `--use-starttls / --no-use-starttls` | Override STARTTLS |
264
+ | `--timeout SECONDS` | Override timeout |
265
+ | `--raise-on-invalid-recipient / --no-raise-on-invalid-recipient` | Override invalid-recipient handling |
270
266
 
271
267
  **Exit codes:** 0, 2 (usage error), 22 (invalid option value), 69 (SMTP failure), 78 (no SMTP hosts, an invalid `[email]` section, or configuration not loadable)
272
268
 
@@ -413,7 +409,7 @@ The `adapters/memory/` package provides lightweight implementations for testing:
413
409
  |---------------------|-----------------------------------------------------------------------------|
414
410
  | `memory/config.py` | `GetConfig`, `GetDefaultConfigPath`, `DeployConfiguration`, `DisplayConfig` |
415
411
  | `memory/email.py` | `SendEmail`, `SendNotification` |
416
- | `memory/logging.py` | `InitLogging` |
412
+ | `memory/logging.py` | `InitLogging`, `RestartLogging` |
417
413
 
418
414
  `adapters/memory` exports `EmailSpy` (from `memory/email.py`): its `send_email` and
419
415
  `send_notification` methods satisfy the email ports and record each call as a `CapturedEmail` /
@@ -425,16 +421,17 @@ Use `composition.build_testing()` to wire all in-memory adapters. It wires the r
425
421
 
426
422
  ### Test Fixtures (conftest.py)
427
423
 
428
- | Fixture | Purpose |
429
- |---------------------------|-----------------------------------------------------------------------------------------------------------------------|
430
- | `config_factory` | Creates real `Config` instances from test data |
431
- | `inject_config` | Injects config into CLI path |
432
- | `cli_runner` | Fresh `CliRunner` per test |
433
- | `strip_ansi` | Strips ANSI escape codes from output |
434
- | `clear_config_cache` | Clears LRU cache before tests |
435
- | `managed_traceback_state` | Resets/restores traceback configuration |
436
- | `isolated_logging_state` | Autouse: after every test, shuts the lib_log_rich runtime down and restores the root logger; yields that restore step |
424
+ | Fixture | Purpose |
425
+ |---------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
426
+ | `config_factory` | Creates real `Config` instances from test data |
427
+ | `inject_config` | Injects config into CLI path |
428
+ | `cli_runner` | Fresh `CliRunner` per test |
429
+ | `strip_ansi` | Strips ANSI escape codes from output |
430
+ | `clear_config_cache` | Clears LRU cache before tests |
431
+ | `managed_traceback_state` | Resets/restores traceback configuration |
432
+ | `isolated_logging_state` | Autouse: after every test, shuts the lib_log_rich runtime down and restores the root logger; yields that restore step |
433
+ | `user_layer_in_tmp_path` | Points every OS's user-layer location (`HOME`, `USERPROFILE`, `XDG_CONFIG_HOME`, `APPDATA`, `LOCALAPPDATA`) at `tmp_path` and returns it, so a real deploy writes there |
437
434
 
438
435
  ---
439
436
 
440
- **Last Updated:** 2026-10-06 (file index, CLI options and public names brought in line with the source tree)
437
+ **Last Updated:** 2026-10-08 (`send-notification` options, test fixtures)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bitranox_template_py_cli"
3
- version = "2.1.2"
3
+ version = "2.2.0"
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.2.0"
44
44
  #: Repository homepage presented to users.
45
45
  homepage = "https://github.com/bitranox/bitranox_template_py_cli"
46
46
  #: Author attribution surfaced in CLI output.
@@ -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)
@@ -84,9 +84,10 @@ def smtp_config_options(func: Callable[..., Any]) -> Callable[..., Any]:
84
84
  """Apply shared SMTP configuration override options to a Click command.
85
85
 
86
86
  Adds CLI flags for the SMTP connection and delivery settings (hosts, credentials,
87
- STARTTLS, timeout, the two raise_on_* switches). ``starttls_verify``,
88
- ``local_hostname``, ``delivery_deadline``, ``recipient_max_count`` and the attachment
89
- settings have no flag; set them with ``--set email.<key>=...``.
87
+ STARTTLS, timeout, ``raise_on_invalid_recipient``). ``send-email`` adds its own
88
+ ``--raise-on-missing-attachments`` switch, since only it takes attachments.
89
+ ``starttls_verify``, ``local_hostname``, ``delivery_deadline``, ``recipient_max_count``
90
+ and the attachment settings have no flag; set them with ``--set email.<key>=...``.
90
91
  """
91
92
  options = [
92
93
  option(
@@ -100,11 +101,6 @@ def smtp_config_options(func: Callable[..., Any]) -> Callable[..., Any]:
100
101
  option("--smtp-password", default=None, help="Override SMTP authentication password"),
101
102
  option("--use-starttls/--no-use-starttls", default=None, help="Override STARTTLS setting"),
102
103
  option("--timeout", "timeout", type=float, default=None, help="Override socket timeout in seconds"),
103
- option(
104
- "--raise-on-missing-attachments/--no-raise-on-missing-attachments",
105
- default=None,
106
- help="Override missing attachment handling",
107
- ),
108
104
  option(
109
105
  "--raise-on-invalid-recipient/--no-raise-on-invalid-recipient",
110
106
  default=None,
@@ -73,6 +73,11 @@ def _log_send_email_start(
73
73
  type=click.Path(path_type=str),
74
74
  help="File to attach (can specify multiple)",
75
75
  )
76
+ @option(
77
+ "--raise-on-missing-attachments/--no-raise-on-missing-attachments",
78
+ default=None,
79
+ help="Override missing attachment handling",
80
+ )
76
81
  @smtp_config_options
77
82
  @click.pass_context
78
83
  def cli_send_email(
@@ -53,7 +53,6 @@ def cli_send_notification(
53
53
  smtp_password: str | None,
54
54
  use_starttls: bool | None,
55
55
  timeout: float | None,
56
- raise_on_missing_attachments: bool | None,
57
56
  raise_on_invalid_recipient: bool | None,
58
57
  ) -> None:
59
58
  """Send a simple plain-text notification email.
@@ -77,7 +76,6 @@ def cli_send_notification(
77
76
  smtp_password=smtp_password,
78
77
  smtp_use_starttls=use_starttls,
79
78
  smtp_timeout=timeout,
80
- raise_on_missing_attachments=raise_on_missing_attachments,
81
79
  raise_on_invalid_recipient=raise_on_invalid_recipient,
82
80
  )
83
81
  try:
@@ -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
  ]
@@ -212,6 +212,18 @@ def production_factory() -> Callable[[], AppServices]:
212
212
  return build_production
213
213
 
214
214
 
215
+ @pytest.fixture
216
+ def user_layer_in_tmp_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
217
+ """Point every OS's user-layer location at ``tmp_path`` and return it.
218
+
219
+ A deploy then writes under ``tmp_path`` rather than into the real user configuration
220
+ directory, and the developer's own user-layer files cannot decide a result.
221
+ """
222
+ for name in ("HOME", "USERPROFILE", "XDG_CONFIG_HOME", "APPDATA", "LOCALAPPDATA"):
223
+ monkeypatch.setenv(name, str(tmp_path))
224
+ return tmp_path
225
+
226
+
215
227
  @pytest.fixture
216
228
  def strip_ansi() -> Callable[[str], str]:
217
229
  """Return a helper that strips ANSI escape sequences from a string.
@@ -384,7 +396,7 @@ def inject_config(
384
396
  result = cli_runner.invoke(cli, ["config"], obj=factory)
385
397
  assert "key" in result.output
386
398
  """
387
- from bitranox_template_py_cli.adapters.memory import init_logging_in_memory
399
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
388
400
  from bitranox_template_py_cli.composition import AppServices, build_production
389
401
 
390
402
  def _inject(config: Config) -> Callable[[], AppServices]:
@@ -403,6 +415,7 @@ def inject_config(
403
415
  # The quiet runtime: production init_logging queues INFO lines that race into
404
416
  # CliRunner's stderr, so a stderr assertion would depend on timing.
405
417
  init_logging=init_logging_in_memory,
418
+ restart_logging=restart_logging_in_memory,
406
419
  )
407
420
  return lambda: test_services
408
421
 
@@ -438,7 +451,7 @@ def inject_config_with_profile_capture(
438
451
  cli_runner.invoke(cli, ["--profile", "staging", "config"], obj=factory)
439
452
  assert captured == ["staging"]
440
453
  """
441
- from bitranox_template_py_cli.adapters.memory import init_logging_in_memory
454
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
442
455
  from bitranox_template_py_cli.composition import AppServices, build_production
443
456
 
444
457
  def _inject(config: Config, captured_profiles: list[str | None]) -> Callable[[], AppServices]:
@@ -458,6 +471,7 @@ def inject_config_with_profile_capture(
458
471
  # The quiet runtime: production init_logging queues INFO lines that race into
459
472
  # CliRunner's stderr, so a stderr assertion would depend on timing.
460
473
  init_logging=init_logging_in_memory,
474
+ restart_logging=restart_logging_in_memory,
461
475
  )
462
476
  return lambda: test_services
463
477
 
@@ -493,7 +507,7 @@ def inject_deploy_with_profile_capture(
493
507
  cli_runner.invoke(cli, ["--profile", "prod", "config-deploy", ...], obj=factory)
494
508
  assert captured == ["prod"]
495
509
  """
496
- from bitranox_template_py_cli.adapters.memory import init_logging_in_memory
510
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
497
511
  from bitranox_template_py_cli.composition import AppServices, build_production
498
512
 
499
513
  def _inject(deployed_path: Path, captured_profiles: list[str | None]) -> Callable[[], AppServices]:
@@ -522,6 +536,7 @@ def inject_deploy_with_profile_capture(
522
536
  # The quiet runtime: production init_logging queues INFO lines that race into
523
537
  # CliRunner's stderr, so a stderr assertion would depend on timing.
524
538
  init_logging=init_logging_in_memory,
539
+ restart_logging=restart_logging_in_memory,
525
540
  )
526
541
  return lambda: test_services
527
542
 
@@ -553,7 +568,7 @@ def inject_deploy_configuration() -> Callable[[Callable[..., list[Path]]], Calla
553
568
  cli_runner.invoke(cli, ["config-deploy", "--target", "user"], obj=factory)
554
569
  assert len(calls) == 1
555
570
  """
556
- from bitranox_template_py_cli.adapters.memory import init_logging_in_memory
571
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
557
572
  from bitranox_template_py_cli.composition import AppServices, build_production
558
573
 
559
574
  def _inject(deploy_fn: Callable[..., list[Path]]) -> Callable[[], AppServices]:
@@ -569,6 +584,7 @@ def inject_deploy_configuration() -> Callable[[Callable[..., list[Path]]], Calla
569
584
  # The quiet runtime: production init_logging queues INFO lines that race into
570
585
  # CliRunner's stderr, so a stderr assertion would depend on timing.
571
586
  init_logging=init_logging_in_memory,
587
+ restart_logging=restart_logging_in_memory,
572
588
  )
573
589
  return lambda: test_services
574
590
 
@@ -654,7 +670,7 @@ def email_cli_context(
654
670
  assert ctx.spy.sent_notifications[0].subject == "Hi"
655
671
  """
656
672
  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
673
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
658
674
  from bitranox_template_py_cli.adapters.memory.email import EmailSpy as EmailSpyImpl
659
675
  from bitranox_template_py_cli.composition import AppServices, build_production
660
676
 
@@ -677,6 +693,7 @@ def email_cli_context(
677
693
  # The quiet runtime: production init_logging queues INFO lines that race into
678
694
  # CliRunner's stderr, so a stderr assertion would depend on timing.
679
695
  init_logging=init_logging_in_memory,
696
+ restart_logging=restart_logging_in_memory,
680
697
  )
681
698
  return EmailCliContext(factory=lambda: test_services, spy=spy)
682
699
 
@@ -708,7 +725,7 @@ def config_cli_context(
708
725
  result = cli_runner.invoke(cli, ["config"], obj=factory)
709
726
  assert "key" in result.output
710
727
  """
711
- from bitranox_template_py_cli.adapters.memory import init_logging_in_memory
728
+ from bitranox_template_py_cli.adapters.memory import init_logging_in_memory, restart_logging_in_memory
712
729
  from bitranox_template_py_cli.composition import AppServices, build_production
713
730
 
714
731
  def _create(config_data: dict[str, Any]) -> Callable[[], AppServices]:
@@ -729,6 +746,7 @@ def config_cli_context(
729
746
  # The quiet runtime: production init_logging queues INFO lines that race into
730
747
  # CliRunner's stderr, so a stderr assertion would depend on timing.
731
748
  init_logging=init_logging_in_memory,
749
+ restart_logging=restart_logging_in_memory,
732
750
  )
733
751
  return lambda: test_services
734
752
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from pathlib import Path
5
6
  from typing import TYPE_CHECKING, Any
6
7
 
7
8
  import pytest
@@ -11,7 +12,6 @@ from bitranox_template_py_cli.adapters.config import loader as config_mod
11
12
 
12
13
  if TYPE_CHECKING:
13
14
  from collections.abc import Callable, Mapping
14
- from pathlib import Path
15
15
 
16
16
  from click.testing import CliRunner, Result
17
17
  from lib_layered_config import Config
@@ -257,6 +257,27 @@ def test_when_config_deploy_finds_no_files_to_create_it_informs_user(
257
257
  assert "--force" in result.output
258
258
 
259
259
 
260
+ @pytest.mark.os_agnostic
261
+ def test_when_a_forced_config_deploy_finds_every_file_identical_it_says_nothing_was_written(
262
+ cli_runner: CliRunner,
263
+ production_factory: Callable[[], Any],
264
+ user_layer_in_tmp_path: Path,
265
+ ) -> None:
266
+ """A second forced deploy writes nothing, and does not suggest the --force it was given."""
267
+ command = ["config-deploy", "--target", "user", "--force"]
268
+ first: Result = cli_runner.invoke(cli_mod.cli, command, obj=production_factory)
269
+ written = [Path(line.removeprefix(" + ")) for line in first.output.splitlines() if line.startswith(" + ")]
270
+ assert first.exit_code == 0
271
+ assert written # liveness: the first deploy writes the files the second one finds identical
272
+ assert all(path.resolve().is_relative_to(user_layer_in_tmp_path.resolve()) for path in written)
273
+
274
+ second: Result = cli_runner.invoke(cli_mod.cli, command, obj=production_factory)
275
+
276
+ assert second.exit_code == 0
277
+ assert "No files were written: every target file is already identical" in second.output
278
+ assert "--force" not in second.output
279
+
280
+
260
281
  @pytest.mark.os_agnostic
261
282
  def test_when_config_deploy_encounters_permission_error_it_handles_gracefully(
262
283
  cli_runner: CliRunner,