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.
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/CHANGELOG.md +26 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/PKG-INFO +1 -1
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/docs/systemdesign/module_reference.md +26 -29
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/pyproject.toml +1 -1
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/application/ports.py +11 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/composition/__init__.py +8 -5
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/conftest.py +24 -6
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_config.py +22 -1
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_email.py +39 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_env_file.py +1 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_deploy_permissions.py +1 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_email_password_secrecy.py +2 -5
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_logging_dotenv_isolation.py +84 -2
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_permission_defaults.py +1 -3
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_ports.py +14 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.devcontainer/devcontainer.json +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.devcontainer/settings.json +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.env.example +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.gitattributes +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.github/actions/extract-metadata/action.yml +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.github/dependabot.yml +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.github/workflows/codeql.yml +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.github/workflows/default_cicd_public.yml +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.github/workflows/default_release_public.yml +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.gitignore +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.qlty/qlty.toml +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/.snyk +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/CONFIG.md +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/CONTRIBUTING.md +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/DEVELOPMENT.md +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/INSTALL.md +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/LICENSE +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/Makefile +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/README.md +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/SECURITY.md +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/ai-stance.md +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/ai-transparency.md +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/codecov.yml +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/docs/adr/0001-memory-adapters-in-src.md +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/notebooks/Quickstart.ipynb +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/rename.sh +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/rename_dry.sh +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/reset_git_history.sh +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/__main__.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/application/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/application/py.typed +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/composition/py.typed +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/domain/enums.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/domain/errors.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/domain/py.typed +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/entry.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/src/bitranox_template_py_cli/py.typed +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_behaviors.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cache_effectiveness.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_attachment_refused.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_config_errors.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_core.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_email_config_errors.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_exit_codes.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_main_exit.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_overrides.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_cli_validation.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_config_overrides.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_declared_dependencies.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_deploy_mode_safety.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_display.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_email_attachment_lists.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_email_config_translation.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_email_shipped_defaults.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_enums.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_errors.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_logging.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_mail.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_memory_logging.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_metadata.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_metadata_sync.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_module_entry.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_module_reference_sync.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_property_email.py +0 -0
- {bitranox_template_py_cli-2.1.2 → bitranox_template_py_cli-2.2.0}/tests/test_property_overrides.py +0 -0
- {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.
|
|
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
|
|
255
|
-
|
|
256
|
-
| `--to ADDRESS`
|
|
257
|
-
| `--subject TEXT`
|
|
258
|
-
| `--message TEXT`
|
|
259
|
-
| `--from ADDRESS`
|
|
260
|
-
| `--smtp-host HOST:PORT`
|
|
261
|
-
| `--smtp-username USER`
|
|
262
|
-
| `--smtp-password PASS`
|
|
263
|
-
| `--use-starttls / --no-use-starttls`
|
|
264
|
-
| `--timeout SECONDS`
|
|
265
|
-
| `--raise-on-
|
|
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-
|
|
437
|
+
**Last Updated:** 2026-10-08 (`send-notification` options, test fixtures)
|
|
@@ -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.
|
|
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,
|
|
88
|
-
``
|
|
89
|
-
|
|
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__ = [
|
|
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
|
-
|
|
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
|
-
|
|
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,
|