bitranox-template-py-cli 2.1.0__tar.gz → 2.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/CHANGELOG.md +21 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/PKG-INFO +1 -1
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/docs/systemdesign/module_reference.md +7 -6
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/pyproject.toml +1 -1
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/_common.py +2 -2
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/config_load.py +9 -5
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/transport.py +5 -3
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/setup.py +62 -14
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/logging.py +16 -11
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/ports.py +2 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_email_config_translation.py +20 -3
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_logging_dotenv_isolation.py +129 -2
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_mail.py +67 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_memory_logging.py +25 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.devcontainer/devcontainer.json +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.devcontainer/settings.json +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.env.example +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.gitattributes +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.github/actions/extract-metadata/action.yml +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.github/dependabot.yml +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.github/workflows/codeql.yml +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.github/workflows/default_cicd_public.yml +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.github/workflows/default_release_public.yml +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.gitignore +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.qlty/qlty.toml +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.snyk +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/CONFIG.md +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/CONTRIBUTING.md +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/DEVELOPMENT.md +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/INSTALL.md +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/LICENSE +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/Makefile +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/README.md +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/SECURITY.md +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/ai-stance.md +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/ai-transparency.md +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/codecov.yml +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/docs/adr/0001-memory-adapters-in-src.md +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/notebooks/Quickstart.ipynb +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/rename.sh +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/rename_dry.sh +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/reset_git_history.sh +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__main__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/config.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_email.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_notification.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/safe_console.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/typed_click.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/40-layered-config.toml +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/90-logging.toml +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/overrides.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/config.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/email.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/py.typed +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/composition/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/composition/py.typed +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/enums.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/errors.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/py.typed +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/entry.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/py.typed +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/conftest.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_behaviors.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cache_effectiveness.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_config.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_config_errors.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_core.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_email.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_email_config_errors.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_env_file.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_exit_codes.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_main_exit.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_overrides.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_validation.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_config_overrides.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_declared_dependencies.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_deploy_mode_safety.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_deploy_permissions.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_display.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_email_attachment_lists.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_email_password_secrecy.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_email_shipped_defaults.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_enums.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_errors.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_logging.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_metadata.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_metadata_sync.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_module_entry.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_module_reference_sync.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_permission_defaults.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_ports.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_property_email.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_property_overrides.py +0 -0
- {bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_safe_console.py +0 -0
|
@@ -6,6 +6,27 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.1.1] 2026-10-06 18:13:05
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- **A refused `LOG_*` variable no longer disables every command (exit code change).** A value
|
|
13
|
+
lib_log_rich refuses in a `LOG_*` variable, set in the environment or in the `.env` logging
|
|
14
|
+
reads (`LOG_CONSOLE_LEVEL=bogus` in the `--env-file`), made every command, `info` and
|
|
15
|
+
`config-deploy` included, exit 1 with `InvalidLoggingConfigError: lib_log_rich: Unknown log
|
|
16
|
+
level: 'bogus'`: the fallback restarted logging with an empty configuration, but lib_log_rich
|
|
17
|
+
reads the `LOG_*` variables on every start and refused the same variable again. Logging now
|
|
18
|
+
falls back to its defaults with every `LOG_*` variable hidden for that start (and put back
|
|
19
|
+
afterwards), so only the commands that read the configuration (`config`, `send-email`,
|
|
20
|
+
`send-notification`) exit 78, and the others run with exit 0. The 78 carries lib_log_rich's
|
|
21
|
+
own message, `Error: lib_log_rich: Unknown log level: 'bogus'`, which may name neither the
|
|
22
|
+
variable nor where it was set. A refused `[lib_log_rich]` value still leaves every valid
|
|
23
|
+
`LOG_*` variable in force for the fallback, as in 2.1.0: only a refused variable hides them.
|
|
24
|
+
- **The testing composition ignores the developer's `LOG_*` variables.** `build_testing()`'s
|
|
25
|
+
logging runtime (`init_logging_in_memory`) now starts with every `LOG_*` variable hidden and
|
|
26
|
+
puts them back afterwards. A `LOG_CONSOLE_LEVEL=bogus` in the shell running the tests made
|
|
27
|
+
every command under `build_testing()` fail with `ValueError: Unknown log level: 'bogus'`, and a
|
|
28
|
+
valid one changed the quiet test runtime. Production logging is unaffected.
|
|
29
|
+
|
|
9
30
|
## [2.1.0] 2026-10-06 15:37:27
|
|
10
31
|
|
|
11
32
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bitranox_template_py_cli
|
|
3
|
-
Version: 2.1.
|
|
3
|
+
Version: 2.1.1
|
|
4
4
|
Summary: Template CLI application with configuration management and structured logging
|
|
5
5
|
Project-URL: Homepage, https://github.com/bitranox/bitranox_template_py_cli
|
|
6
6
|
Project-URL: Repository, https://github.com/bitranox/bitranox_template_py_cli.git
|
|
@@ -26,7 +26,7 @@ moves or removes a module, a CLI option or a public name.
|
|
|
26
26
|
- `src/bitranox_template_py_cli/adapters/email/transport.py` - `send_email` / `send_notification` over btx_lib_mail
|
|
27
27
|
- `src/bitranox_template_py_cli/adapters/email/sender.py` - Re-exports of `config.py` and `transport.py`
|
|
28
28
|
- `src/bitranox_template_py_cli/adapters/email/validation.py` - Email recipient validation
|
|
29
|
-
- `src/bitranox_template_py_cli/adapters/logging/setup.py` - lib_log_rich initialization
|
|
29
|
+
- `src/bitranox_template_py_cli/adapters/logging/setup.py` - lib_log_rich initialization; takes only the `LOG_*` lines of a `.env`, and raises `InvalidLoggingConfigError` for a refused `[lib_log_rich]` value or `LOG_*` variable, after starting logging with its defaults (and without the `LOG_*` variables only when one of them is refused)
|
|
30
30
|
- `src/bitranox_template_py_cli/adapters/cli/` - CLI adapter package:
|
|
31
31
|
- `__init__.py` - Public facade
|
|
32
32
|
- `constants.py` - Shared constants
|
|
@@ -92,6 +92,7 @@ moves or removes a module, a CLI option or a public name.
|
|
|
92
92
|
- `tests/test_enums.py` - Domain enum tests
|
|
93
93
|
- `tests/test_errors.py` - Domain error types
|
|
94
94
|
- `tests/test_logging.py` - Logging configuration model
|
|
95
|
+
- `tests/test_logging_dotenv_isolation.py` - Only `LOG_*` lines of a `.env` reach the environment; an invalid `[lib_log_rich]` section or `LOG_*` variable is a configuration failure that leaves logging running
|
|
95
96
|
- `tests/test_mail.py` - Email configuration and sending tests (the `integration` ones send real mail)
|
|
96
97
|
- `tests/test_memory_logging.py` - Testing-composition logging runtime and the per-test logging reset
|
|
97
98
|
- `tests/test_metadata.py` - Package metadata and PEP 561 marker tests
|
|
@@ -322,9 +323,9 @@ Raises `ValueError` with descriptive message on invalid input.
|
|
|
322
323
|
`EmailConfig` (`adapters/email/config.py`, re-exported by `adapters/email/sender.py`) subclasses
|
|
323
324
|
btx_lib_mail's `ConfMail`: frozen, a name that is not a field is refused, the password is a
|
|
324
325
|
`SecretStr`, and a validation error never shows the password or a host. It adds `from_address` and
|
|
325
|
-
`recipients
|
|
326
|
-
|
|
327
|
-
column).
|
|
326
|
+
`recipients`. ConfMail checks every SMTP host's syntax (port range, IPv6 brackets, host name
|
|
327
|
+
labels) when it loads, so `EmailConfig` has no host check of its own. In Python the fields use
|
|
328
|
+
the library's names; the configuration file keeps its own keys (fourth column).
|
|
328
329
|
|
|
329
330
|
| Field | Type | Default | File key (`[email]`) | Description |
|
|
330
331
|
|--------------------------------|---------------------|---------|--------------------------------|------------------------------------------------------------------|
|
|
@@ -362,7 +363,7 @@ lib_layered_config is the only reader of configuration: it merges every layer (t
|
|
|
362
363
|
defaults, the app, host and user files, `.env`, the environment, `--set`) into one mapping.
|
|
363
364
|
`load_email_config_from_dict()` turns that mapping's `[email]` section into an `EmailConfig`:
|
|
364
365
|
|
|
365
|
-
- the six keys in the
|
|
366
|
+
- the six keys in the fourth column that differ from the field names are mapped;
|
|
366
367
|
- `[email.attachments]` keys become `attachment_<key>`;
|
|
367
368
|
- a key that is not listed is refused (exit 78, `email.<key>: unknown key`), whatever layer it came from;
|
|
368
369
|
- blank text means "not configured"; a single host or address string is a one-entry list;
|
|
@@ -379,7 +380,7 @@ problem, never with the refused value.
|
|
|
379
380
|
|---------------------|------------------------------------------------------------------------------|
|
|
380
381
|
| `SECTION_KEYS` | Every key `[email]` accepts (the `attachments` table included) |
|
|
381
382
|
| `ATTACHMENT_KEYS` | Every key `[email.attachments]` accepts; each names field `attachment_<key>` |
|
|
382
|
-
| `FILE_KEY_TO_FIELD` | File key -> `EmailConfig` field, for the
|
|
383
|
+
| `FILE_KEY_TO_FIELD` | File key -> `EmailConfig` field, for the six keys whose names differ |
|
|
383
384
|
|
|
384
385
|
---
|
|
385
386
|
|
|
@@ -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.
|
|
43
|
+
version = "2.1.1"
|
|
44
44
|
#: Repository homepage presented to users.
|
|
45
45
|
homepage = "https://github.com/bitranox/bitranox_template_py_cli"
|
|
46
46
|
#: Author attribution surfaced in CLI output.
|
|
@@ -84,8 +84,8 @@ def smtp_config_options(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
|
84
84
|
|
|
85
85
|
Adds CLI flags for the SMTP connection and delivery settings (hosts, credentials,
|
|
86
86
|
STARTTLS, timeout, the two raise_on_* switches). ``starttls_verify``,
|
|
87
|
-
``local_hostname`` and the attachment
|
|
88
|
-
``--set email.<key>=...``.
|
|
87
|
+
``local_hostname``, ``delivery_deadline``, ``recipient_max_count`` and the attachment
|
|
88
|
+
settings have no flag; set them with ``--set email.<key>=...``.
|
|
89
89
|
"""
|
|
90
90
|
options = [
|
|
91
91
|
option(
|
|
@@ -14,7 +14,8 @@ configuration.
|
|
|
14
14
|
|
|
15
15
|
Contents:
|
|
16
16
|
* :func:`load_config` - load with profile, ``.env`` and ``--set``, or say why not.
|
|
17
|
-
* :func:`start_logging` - start logging; an invalid ``[lib_log_rich]``
|
|
17
|
+
* :func:`start_logging` - start logging; an invalid ``[lib_log_rich]`` value or ``LOG_*``
|
|
18
|
+
variable is a load failure.
|
|
18
19
|
* :func:`require_config` - the configuration, or exit 78 naming the failure.
|
|
19
20
|
* :func:`report_load_failure` - the one-line report, after the traceback on request.
|
|
20
21
|
* :func:`echo_load_traceback` - the loader's traceback alone, for a caller with its own line.
|
|
@@ -97,9 +98,13 @@ def start_logging(
|
|
|
97
98
|
) -> tuple[Config, Exception | None]:
|
|
98
99
|
"""Start logging with ``config``; a logging section it refuses is recorded like a load failure.
|
|
99
100
|
|
|
100
|
-
An invalid ``[lib_log_rich]`` value would otherwise stop every
|
|
101
|
-
(which replaces the file holding it) included.
|
|
102
|
-
|
|
101
|
+
An invalid ``[lib_log_rich]`` value or ``LOG_*`` variable would otherwise stop every
|
|
102
|
+
command, ``config-deploy`` (which replaces the file holding it) included. ``init_logging``
|
|
103
|
+
then starts logging with its defaults (and no ``LOG_*`` variable, if one of them is the
|
|
104
|
+
refused setting) before it raises, and the commands that read the configuration refuse with
|
|
105
|
+
exit 78 and the refusal: ``lib_log_rich.<key>: <reason>`` for a problem the section's type
|
|
106
|
+
check finds, lib_log_rich's own message for a value only lib_log_rich refuses, whether it
|
|
107
|
+
came from the section or a ``LOG_*`` variable (``lib_log_rich: Unknown log level: 'bogus'``).
|
|
103
108
|
|
|
104
109
|
Args:
|
|
105
110
|
services: The composition's services; only ``init_logging`` is used.
|
|
@@ -114,7 +119,6 @@ def start_logging(
|
|
|
114
119
|
try:
|
|
115
120
|
services.init_logging(config, dotenv_path=env_file)
|
|
116
121
|
except InvalidLoggingConfigError as exc:
|
|
117
|
-
services.init_logging(Config({}, {}), dotenv_path=env_file)
|
|
118
122
|
return Config({}, {}), config_error or exc
|
|
119
123
|
return config, config_error
|
|
120
124
|
|
|
@@ -122,10 +122,12 @@ def send_email(
|
|
|
122
122
|
|
|
123
123
|
Raises:
|
|
124
124
|
ValueError: No from_address configured and no override provided,
|
|
125
|
-
or no recipients configured and no override provided
|
|
125
|
+
or no recipients configured and no override provided, or more
|
|
126
|
+
recipients or attachments than config.recipient_max_count or
|
|
127
|
+
config.attachment_max_count allow (refused before any delivery).
|
|
126
128
|
ConfigurationError: No SMTP hosts configured.
|
|
127
|
-
FileNotFoundError: Required attachment missing and
|
|
128
|
-
is True.
|
|
129
|
+
FileNotFoundError: Required attachment missing or unreadable and
|
|
130
|
+
config.raise_on_missing_attachments is True.
|
|
129
131
|
DeliveryError: All SMTP hosts failed for a recipient.
|
|
130
132
|
|
|
131
133
|
Side Effects:
|
|
@@ -5,8 +5,10 @@ eliminating duplication between module entry (__main__.py) and console script
|
|
|
5
5
|
(cli.py) while ensuring initialization happens exactly once.
|
|
6
6
|
|
|
7
7
|
Contents:
|
|
8
|
-
* :func:`init_logging` - idempotent logging initialization
|
|
9
|
-
|
|
8
|
+
* :func:`init_logging` - idempotent logging initialization from the ``[lib_log_rich]``
|
|
9
|
+
section and the ``LOG_*`` variables (the environment, plus the ``LOG_*`` lines of a ``.env``).
|
|
10
|
+
* :class:`InvalidLoggingConfigError` - the ``[lib_log_rich]`` section or a ``LOG_*`` variable
|
|
11
|
+
cannot configure logging.
|
|
10
12
|
* :func:`_build_runtime_config` - constructs RuntimeConfig from layered sources.
|
|
11
13
|
|
|
12
14
|
System Role:
|
|
@@ -18,18 +20,20 @@ System Role:
|
|
|
18
20
|
from __future__ import annotations
|
|
19
21
|
|
|
20
22
|
import os
|
|
23
|
+
from contextlib import contextmanager
|
|
21
24
|
from pathlib import Path
|
|
22
25
|
from typing import TYPE_CHECKING, cast
|
|
23
26
|
|
|
24
27
|
import lib_log_rich.runtime
|
|
25
28
|
from dotenv import dotenv_values
|
|
29
|
+
from lib_layered_config import Config
|
|
26
30
|
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
27
31
|
|
|
28
32
|
from bitranox_template_py_cli import __init__conf__
|
|
29
33
|
from bitranox_template_py_cli.domain.errors import ConfigurationError
|
|
30
34
|
|
|
31
35
|
if TYPE_CHECKING:
|
|
32
|
-
from
|
|
36
|
+
from collections.abc import Generator
|
|
33
37
|
|
|
34
38
|
|
|
35
39
|
class LoggingConfigModel(BaseModel):
|
|
@@ -94,18 +98,20 @@ def _build_runtime_config(config: Config) -> lib_log_rich.runtime.RuntimeConfig:
|
|
|
94
98
|
class InvalidLoggingConfigError(ConfigurationError):
|
|
95
99
|
"""lib_log_rich refuses its settings: one ``<key>: <reason>`` in :attr:`problems` per problem.
|
|
96
100
|
|
|
97
|
-
The settings are the ``[lib_log_rich]`` section plus any ``LOG_*`` variable. A problem
|
|
98
|
-
the key and never repeats the refused value.
|
|
101
|
+
The settings are the ``[lib_log_rich]`` section plus any ``LOG_*`` variable. A problem the
|
|
102
|
+
type check of the section finds names the key and never repeats the refused value. A value
|
|
103
|
+
only lib_log_rich itself refuses, such as an unknown level in ``LOG_CONSOLE_LEVEL`` or in the
|
|
104
|
+
section's ``console_level``, is reported in lib_log_rich's own words (``lib_log_rich: Unknown
|
|
105
|
+
log level: 'bogus'``), which may name neither the setting nor where it was set.
|
|
99
106
|
|
|
100
107
|
Attributes:
|
|
101
108
|
problems: One line per refused setting.
|
|
102
109
|
|
|
103
110
|
Example:
|
|
104
|
-
>>> from lib_layered_config import Config
|
|
105
111
|
>>> try:
|
|
106
|
-
...
|
|
107
|
-
... except
|
|
108
|
-
... print(exc)
|
|
112
|
+
... _build_runtime_config(Config({"lib_log_rich": {"rate_limit": "100:60"}}, {}))
|
|
113
|
+
... except ValidationError as exc:
|
|
114
|
+
... print(InvalidLoggingConfigError(_problems(exc)))
|
|
109
115
|
lib_log_rich.rate_limit: Input should be a valid tuple
|
|
110
116
|
"""
|
|
111
117
|
|
|
@@ -126,8 +132,8 @@ def _problems(error: BaseException) -> list[str]:
|
|
|
126
132
|
while cause is not None and not isinstance(cause, ValidationError):
|
|
127
133
|
cause = cause.__cause__ or cause.__context__
|
|
128
134
|
if cause is None:
|
|
129
|
-
# Not a pydantic error (e.g. an unknown level name
|
|
130
|
-
# the
|
|
135
|
+
# Not a pydantic error (e.g. an unknown level name in a LOG_* variable): lib_log_rich's
|
|
136
|
+
# own first line, which may name neither the variable nor where it was set.
|
|
131
137
|
return [f"lib_log_rich: {str(error).splitlines()[0]}"]
|
|
132
138
|
return [f"lib_log_rich.{'.'.join(str(part) for part in item['loc'])}: {item['msg']}" for item in cause.errors()]
|
|
133
139
|
|
|
@@ -182,6 +188,41 @@ def _load_log_variables(dotenv_path: str | None) -> None:
|
|
|
182
188
|
os.environ.setdefault(name, value)
|
|
183
189
|
|
|
184
190
|
|
|
191
|
+
@contextmanager
|
|
192
|
+
def log_variables_hidden() -> Generator[None]:
|
|
193
|
+
"""Remove every ``LOG_*`` variable from the environment for the block, then put each back.
|
|
194
|
+
|
|
195
|
+
Example:
|
|
196
|
+
>>> os.environ["LOG_HIDDEN_PROBE"] = "x"
|
|
197
|
+
>>> with log_variables_hidden():
|
|
198
|
+
... "LOG_HIDDEN_PROBE" in os.environ
|
|
199
|
+
False
|
|
200
|
+
>>> os.environ.pop("LOG_HIDDEN_PROBE")
|
|
201
|
+
'x'
|
|
202
|
+
"""
|
|
203
|
+
names = [name for name in os.environ if name.startswith(_LOG_VARIABLE_PREFIX)]
|
|
204
|
+
hidden = {name: os.environ.pop(name) for name in names}
|
|
205
|
+
try:
|
|
206
|
+
yield
|
|
207
|
+
finally:
|
|
208
|
+
os.environ.update(hidden)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _start_default_logging() -> None:
|
|
212
|
+
"""Start lib_log_rich with the package defaults; without the ``LOG_*`` variables only if needed.
|
|
213
|
+
|
|
214
|
+
lib_log_rich reads the ``LOG_*`` variables from the environment on every init. When the
|
|
215
|
+
refused setting came from the ``[lib_log_rich]`` section, the defaults start with them and
|
|
216
|
+
a valid ``LOG_CONSOLE_LEVEL`` still applies. When one of them is the refused setting, that
|
|
217
|
+
start is refused again, and only then do the defaults start with every ``LOG_*`` hidden.
|
|
218
|
+
"""
|
|
219
|
+
try:
|
|
220
|
+
lib_log_rich.runtime.init(_build_runtime_config(Config({}, {})))
|
|
221
|
+
except ValueError: # pydantic's ValidationError is a ValueError
|
|
222
|
+
with log_variables_hidden():
|
|
223
|
+
lib_log_rich.runtime.init(_build_runtime_config(Config({}, {})))
|
|
224
|
+
|
|
225
|
+
|
|
185
226
|
def init_logging(config: Config, *, dotenv_path: str | None = None) -> None:
|
|
186
227
|
"""Initialize lib_log_rich runtime with the provided configuration.
|
|
187
228
|
|
|
@@ -199,8 +240,12 @@ def init_logging(config: Config, *, dotenv_path: str | None = None) -> None:
|
|
|
199
240
|
to use the nearest ``.env`` from the working directory up to the project root.
|
|
200
241
|
|
|
201
242
|
Raises:
|
|
202
|
-
InvalidLoggingConfigError: The ``[lib_log_rich]`` section
|
|
203
|
-
refuses
|
|
243
|
+
InvalidLoggingConfigError: The ``[lib_log_rich]`` section or a ``LOG_*`` variable holds
|
|
244
|
+
a value lib_log_rich refuses. Logging is started anyway, with the package defaults
|
|
245
|
+
and the ``LOG_*`` variables (every one of them ignored if the defaults are refused
|
|
246
|
+
with them too), so the caller can record the failure and run the commands that do
|
|
247
|
+
not read the configuration. Only the first call can raise: logging is running after
|
|
248
|
+
it, so a later call returns at once, also with the same refused configuration.
|
|
204
249
|
|
|
205
250
|
Side Effects:
|
|
206
251
|
Copies the ``LOG_*`` lines of that ``.env`` into the process environment on first
|
|
@@ -226,9 +271,11 @@ def init_logging(config: Config, *, dotenv_path: str | None = None) -> None:
|
|
|
226
271
|
_load_log_variables(dotenv_path)
|
|
227
272
|
try:
|
|
228
273
|
lib_log_rich.runtime.init(_build_runtime_config(config))
|
|
229
|
-
except
|
|
274
|
+
except ValueError as exc: # pydantic's ValidationError is a ValueError
|
|
230
275
|
# The type check of the section and lib_log_rich's own range checks (which also see the
|
|
231
276
|
# LOG_* variables) both refuse here; neither has started the runtime.
|
|
277
|
+
_start_default_logging()
|
|
278
|
+
lib_log_rich.runtime.attach_std_logging()
|
|
232
279
|
raise InvalidLoggingConfigError(_problems(exc)) from exc
|
|
233
280
|
lib_log_rich.runtime.attach_std_logging()
|
|
234
281
|
|
|
@@ -237,4 +284,5 @@ __all__ = [
|
|
|
237
284
|
"InvalidLoggingConfigError",
|
|
238
285
|
"LoggingConfigModel",
|
|
239
286
|
"init_logging",
|
|
287
|
+
"log_variables_hidden",
|
|
240
288
|
]
|
|
@@ -12,6 +12,7 @@ import lib_log_rich.runtime
|
|
|
12
12
|
from lib_log_rich.domain import LogLevel
|
|
13
13
|
|
|
14
14
|
from bitranox_template_py_cli import __init__conf__
|
|
15
|
+
from bitranox_template_py_cli.adapters.logging.setup import log_variables_hidden
|
|
15
16
|
|
|
16
17
|
if TYPE_CHECKING:
|
|
17
18
|
from lib_layered_config import Config
|
|
@@ -29,7 +30,10 @@ def init_logging_in_memory(config: Config, *, dotenv_path: str | None = None) ->
|
|
|
29
30
|
double has no business writing to a system log, and a queue is a background thread the
|
|
30
31
|
test run would have to reap. The console level is ERROR so ordinary INFO logging cannot
|
|
31
32
|
land in the output a test asserts on. Unlike the production initializer this never loads
|
|
32
|
-
a ``.env`` file,
|
|
33
|
+
a ``.env`` file, and it starts with every ``LOG_*`` variable hidden (each is put back
|
|
34
|
+
afterwards): lib_log_rich reads them on every init, so a developer's own
|
|
35
|
+
``LOG_CONSOLE_LEVEL`` would otherwise change the test runtime, and a value lib_log_rich
|
|
36
|
+
refuses would fail every command run under ``build_testing()``.
|
|
33
37
|
|
|
34
38
|
Args:
|
|
35
39
|
config: Layered configuration object. Unused: the test runtime is fixed, and the
|
|
@@ -45,17 +49,18 @@ def init_logging_in_memory(config: Config, *, dotenv_path: str | None = None) ->
|
|
|
45
49
|
"""
|
|
46
50
|
if lib_log_rich.runtime.is_initialised():
|
|
47
51
|
return
|
|
48
|
-
|
|
49
|
-
lib_log_rich.runtime.
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
with log_variables_hidden():
|
|
53
|
+
lib_log_rich.runtime.init(
|
|
54
|
+
lib_log_rich.runtime.RuntimeConfig(
|
|
55
|
+
service=f"{__init__conf__.name}-test",
|
|
56
|
+
environment="test",
|
|
57
|
+
console_level=LogLevel.ERROR,
|
|
58
|
+
enable_journald=False,
|
|
59
|
+
enable_eventlog=False,
|
|
60
|
+
enable_graylog=False,
|
|
61
|
+
queue_enabled=False,
|
|
62
|
+
)
|
|
57
63
|
)
|
|
58
|
-
)
|
|
59
64
|
|
|
60
65
|
|
|
61
66
|
__all__ = ["init_logging_in_memory"]
|
|
@@ -102,6 +102,8 @@ class InitLogging(Protocol):
|
|
|
102
102
|
"""Initialize lib_log_rich runtime with the provided configuration.
|
|
103
103
|
|
|
104
104
|
``dotenv_path`` is the ``.env`` the configuration was loaded with, or None for the nearest one.
|
|
105
|
+
A refused logging setting raises ``InvalidLoggingConfigError`` only after logging has been
|
|
106
|
+
started with its defaults, so the caller never has to start it a second time.
|
|
105
107
|
"""
|
|
106
108
|
|
|
107
109
|
def __call__(self, config: Config, *, dotenv_path: str | None = None) -> None: ...
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""The merged [email] section becomes an EmailConfig: file keys kept, unknown keys refused.
|
|
2
2
|
|
|
3
3
|
lib_layered_config merges every layer into one mapping; load_email_config_from_dict reads its
|
|
4
|
-
[email] section.
|
|
4
|
+
[email] section. Six file keys differ from ConfMail's field names, values that mean "not
|
|
5
5
|
configured" are dropped so the library default applies, and a key that is not a file key is
|
|
6
6
|
refused, whatever layer it came from.
|
|
7
7
|
"""
|
|
@@ -33,7 +33,7 @@ def _problems(email: object) -> list[str]:
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
@pytest.mark.os_agnostic
|
|
36
|
-
def
|
|
36
|
+
def test_the_six_renamed_file_keys_reach_their_fields() -> None:
|
|
37
37
|
config = _load(
|
|
38
38
|
{
|
|
39
39
|
"smtp_hosts": ["smtp.example.com:587"],
|
|
@@ -41,6 +41,7 @@ def test_the_five_renamed_file_keys_reach_their_fields() -> None:
|
|
|
41
41
|
"timeout": 12.5,
|
|
42
42
|
"starttls_verify": False,
|
|
43
43
|
"local_hostname": "mail.example.com",
|
|
44
|
+
"delivery_deadline": 120,
|
|
44
45
|
}
|
|
45
46
|
)
|
|
46
47
|
|
|
@@ -49,6 +50,7 @@ def test_the_five_renamed_file_keys_reach_their_fields() -> None:
|
|
|
49
50
|
assert config.smtp_timeout == 12.5
|
|
50
51
|
assert config.smtp_starttls_verify is False
|
|
51
52
|
assert config.smtp_local_hostname == "mail.example.com"
|
|
53
|
+
assert config.smtp_delivery_deadline == 120.0
|
|
52
54
|
|
|
53
55
|
|
|
54
56
|
@pytest.mark.os_agnostic
|
|
@@ -223,7 +225,7 @@ def test_a_directory_list_becomes_paths() -> None:
|
|
|
223
225
|
"host", ["smtp.example.com:99999", "smtp.example.com:abc", "[::1"], ids=["port-range", "port-text", "bracket"]
|
|
224
226
|
)
|
|
225
227
|
def test_a_malformed_host_is_refused_at_load(host: str) -> None:
|
|
226
|
-
"""
|
|
228
|
+
"""A port or bracket typo surfaces when the configuration loads, not at the first delivery."""
|
|
227
229
|
problems = _problems({"smtp_hosts": [host]})
|
|
228
230
|
|
|
229
231
|
assert len(problems) == 1, problems
|
|
@@ -231,6 +233,21 @@ def test_a_malformed_host_is_refused_at_load(host: str) -> None:
|
|
|
231
233
|
assert "Value error" not in problems[0], problems
|
|
232
234
|
|
|
233
235
|
|
|
236
|
+
@pytest.mark.os_agnostic
|
|
237
|
+
@pytest.mark.parametrize(
|
|
238
|
+
"host",
|
|
239
|
+
["smtp.example.com:99999", "smtp.example.com:abc", "[::1", "smtp.example.com:+25", "a..example.com", "[zz]"],
|
|
240
|
+
ids=["port-range", "port-text", "bracket", "port-sign", "empty-label", "bracket-not-ip"],
|
|
241
|
+
)
|
|
242
|
+
def test_conf_mail_itself_refuses_a_malformed_host(host: str) -> None:
|
|
243
|
+
"""EmailConfig has no host check of its own: ConfMail runs validate_smtp_host on every entry.
|
|
244
|
+
|
|
245
|
+
If a btx_lib_mail release stops doing so, this fails, and the check belongs back in EmailConfig.
|
|
246
|
+
"""
|
|
247
|
+
with pytest.raises(ValidationError):
|
|
248
|
+
ConfMail(smtphosts=[host])
|
|
249
|
+
|
|
250
|
+
|
|
234
251
|
@pytest.mark.os_agnostic
|
|
235
252
|
def test_a_host_list_of_the_wrong_type_reads_as_the_file_key_without_a_prefix() -> None:
|
|
236
253
|
"""btx_lib_mail drops ctx for a credential field, so pydantic's prefix is still on the message."""
|
|
@@ -5,7 +5,9 @@ reach the process environment: a later configuration load (``config --profile``,
|
|
|
5
5
|
permission read) would take an app-prefixed line for the environment layer. With ``--env-file``,
|
|
6
6
|
that file is the only ``.env`` read. An invalid ``[lib_log_rich]`` section is a configuration
|
|
7
7
|
failure like a broken file: the commands that read the configuration refuse with exit 78, the
|
|
8
|
-
others still run.
|
|
8
|
+
others still run, logging with its defaults and every valid ``LOG_*`` variable. A refused ``LOG_*``
|
|
9
|
+
variable, from the environment or the ``.env``, is the same failure: logging still starts, with its
|
|
10
|
+
defaults and without any ``LOG_*`` variable.
|
|
9
11
|
|
|
10
12
|
The end-to-end tests run the real CLI in a subprocess with every configuration location under
|
|
11
13
|
``tmp_path``.
|
|
@@ -23,7 +25,7 @@ import pytest
|
|
|
23
25
|
from lib_layered_config import Config
|
|
24
26
|
|
|
25
27
|
from bitranox_template_py_cli import __init__conf__
|
|
26
|
-
from bitranox_template_py_cli.adapters.logging.setup import init_logging
|
|
28
|
+
from bitranox_template_py_cli.adapters.logging.setup import InvalidLoggingConfigError, init_logging
|
|
27
29
|
|
|
28
30
|
if TYPE_CHECKING:
|
|
29
31
|
from collections.abc import Iterator
|
|
@@ -202,3 +204,128 @@ def test_an_invalid_logging_section_does_not_block_the_deploy_that_replaces_it(t
|
|
|
202
204
|
)
|
|
203
205
|
|
|
204
206
|
assert result.returncode == 0, result.stderr
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------ a refused setting still leaves logging running
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@pytest.mark.os_agnostic
|
|
213
|
+
def test_a_refused_logging_section_still_leaves_logging_running() -> None:
|
|
214
|
+
with pytest.raises(InvalidLoggingConfigError):
|
|
215
|
+
init_logging(Config({"lib_log_rich": {"rate_limit": "100:60"}}, {}))
|
|
216
|
+
|
|
217
|
+
assert lib_log_rich.runtime.is_initialised()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@pytest.mark.os_agnostic
|
|
221
|
+
def test_only_the_first_call_raises_for_a_refused_logging_section() -> None:
|
|
222
|
+
"""The fallback leaves logging running, so a later call returns before it checks anything."""
|
|
223
|
+
refused = Config({"lib_log_rich": {"rate_limit": "100:60"}}, {})
|
|
224
|
+
with pytest.raises(InvalidLoggingConfigError):
|
|
225
|
+
init_logging(refused)
|
|
226
|
+
|
|
227
|
+
init_logging(refused)
|
|
228
|
+
|
|
229
|
+
assert lib_log_rich.runtime.is_initialised()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@pytest.mark.os_agnostic
|
|
233
|
+
def test_a_refused_log_variable_still_leaves_logging_running(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
234
|
+
"""lib_log_rich reads every ``LOG_*`` variable on each init, so the fallback must start without them."""
|
|
235
|
+
monkeypatch.setenv("LOG_CONSOLE_LEVEL", "bogus")
|
|
236
|
+
|
|
237
|
+
with pytest.raises(InvalidLoggingConfigError):
|
|
238
|
+
init_logging(Config({}, {}))
|
|
239
|
+
|
|
240
|
+
assert lib_log_rich.runtime.is_initialised()
|
|
241
|
+
assert os.environ["LOG_CONSOLE_LEVEL"] == "bogus"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@pytest.mark.os_agnostic
|
|
245
|
+
@pytest.mark.parametrize(
|
|
246
|
+
"command", [("info",), ("config-deploy", "--target", "user", "--force")], ids=["info", "deploy"]
|
|
247
|
+
)
|
|
248
|
+
def test_a_refused_log_variable_in_the_env_file_leaves_commands_that_do_not_read_the_config_running(
|
|
249
|
+
tmp_path: Path, command: tuple[str, ...]
|
|
250
|
+
) -> None:
|
|
251
|
+
env_file = tmp_path / "service.env"
|
|
252
|
+
env_file.write_text("LOG_CONSOLE_LEVEL=bogus\n", encoding="utf-8")
|
|
253
|
+
|
|
254
|
+
result = _run(tmp_path, "--env-file", str(env_file), *command)
|
|
255
|
+
|
|
256
|
+
assert result.returncode == 0, result.stderr
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@pytest.mark.os_agnostic
|
|
260
|
+
def test_a_refused_log_variable_in_the_env_file_is_a_configuration_error(tmp_path: Path) -> None:
|
|
261
|
+
env_file = tmp_path / "service.env"
|
|
262
|
+
env_file.write_text("LOG_CONSOLE_LEVEL=bogus\n", encoding="utf-8")
|
|
263
|
+
|
|
264
|
+
result = _run(tmp_path, "--env-file", str(env_file), "config")
|
|
265
|
+
|
|
266
|
+
assert result.returncode == 78, result.stderr
|
|
267
|
+
errors = [line for line in result.stderr.splitlines() if line.startswith("Error:")]
|
|
268
|
+
assert len(errors) == 1, result.stderr
|
|
269
|
+
assert errors[0].startswith("Error: lib_log_rich")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@pytest.mark.os_agnostic
|
|
273
|
+
def test_a_refused_log_variable_in_the_environment_leaves_info_running(tmp_path: Path) -> None:
|
|
274
|
+
result = _run(tmp_path, "info", env={"LOG_CONSOLE_LEVEL": "bogus"})
|
|
275
|
+
|
|
276
|
+
assert result.returncode == 0, result.stderr
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@pytest.mark.os_agnostic
|
|
280
|
+
def test_every_log_variable_comes_back_even_when_the_default_start_fails(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
281
|
+
"""The fallback start hides the ``LOG_*`` variables; a failure there must not lose them.
|
|
282
|
+
|
|
283
|
+
``lib_log_rich.runtime.init`` is the third-party edge: the first call refuses like a bad
|
|
284
|
+
level, the second (the defaults) fails for a reason of its own.
|
|
285
|
+
"""
|
|
286
|
+
monkeypatch.setenv("LOG_CONSOLE_LEVEL", "bogus")
|
|
287
|
+
variable_seen: list[bool] = []
|
|
288
|
+
|
|
289
|
+
def refuse(_config: object) -> None:
|
|
290
|
+
variable_seen.append("LOG_CONSOLE_LEVEL" in os.environ)
|
|
291
|
+
if "LOG_CONSOLE_LEVEL" in os.environ:
|
|
292
|
+
raise ValueError("Unknown log level: 'bogus'")
|
|
293
|
+
raise RuntimeError("the default start failed too")
|
|
294
|
+
|
|
295
|
+
monkeypatch.setattr(lib_log_rich.runtime, "init", refuse)
|
|
296
|
+
|
|
297
|
+
with pytest.raises(RuntimeError, match="the default start failed too"):
|
|
298
|
+
init_logging(Config({}, {}))
|
|
299
|
+
|
|
300
|
+
# The configuration, then the defaults with the variables, then the defaults without them.
|
|
301
|
+
assert variable_seen == [True, True, False]
|
|
302
|
+
assert os.environ["LOG_CONSOLE_LEVEL"] == "bogus"
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------------------ which LOG_* variables the fallback keeps
|
|
306
|
+
|
|
307
|
+
_INFO_LINE = "Displaying package information"
|
|
308
|
+
_REFUSED_SECTION = ("--set", "lib_log_rich.rate_limit=100:60")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@pytest.mark.os_agnostic
|
|
312
|
+
def test_a_valid_log_variable_is_still_honoured_when_the_logging_section_is_refused(tmp_path: Path) -> None:
|
|
313
|
+
"""Only a refused ``LOG_*`` variable is a reason to start without them; a refused section is not."""
|
|
314
|
+
shown = _run(tmp_path, *_REFUSED_SECTION, "info")
|
|
315
|
+
quieted = _run(tmp_path, *_REFUSED_SECTION, "info", env={"LOG_CONSOLE_LEVEL": "error"})
|
|
316
|
+
|
|
317
|
+
# Premise: at the default console level the fallback shows info's INFO line.
|
|
318
|
+
assert shown.returncode == 0, shown.stderr
|
|
319
|
+
assert _INFO_LINE in shown.stderr, shown.stderr
|
|
320
|
+
assert quieted.returncode == 0, quieted.stderr
|
|
321
|
+
assert _INFO_LINE not in quieted.stderr, quieted.stderr
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@pytest.mark.os_agnostic
|
|
325
|
+
@pytest.mark.parametrize("section", [(), _REFUSED_SECTION], ids=["variable-alone", "with-refused-section"])
|
|
326
|
+
def test_a_refused_log_variable_is_hidden_and_the_fallback_still_logs(tmp_path: Path, section: tuple[str, ...]) -> None:
|
|
327
|
+
"""The defaults start without every ``LOG_*`` variable, and std logging still reaches the console."""
|
|
328
|
+
result = _run(tmp_path, *section, "info", env={"LOG_CONSOLE_LEVEL": "bogus"})
|
|
329
|
+
|
|
330
|
+
assert result.returncode == 0, result.stderr
|
|
331
|
+
assert _INFO_LINE in result.stderr, result.stderr
|
|
@@ -15,6 +15,7 @@ from unittest.mock import patch
|
|
|
15
15
|
|
|
16
16
|
import pytest
|
|
17
17
|
import rtoml
|
|
18
|
+
from btx_lib_mail import ConfMail
|
|
18
19
|
from pydantic import SecretStr
|
|
19
20
|
from pydantic import ValidationError as PydanticValidationError
|
|
20
21
|
|
|
@@ -46,6 +47,7 @@ class _Delivery:
|
|
|
46
47
|
credentials: tuple[str, str] | None
|
|
47
48
|
local_hostname: str | None
|
|
48
49
|
starttls_verify: bool
|
|
50
|
+
deadline: float | None
|
|
49
51
|
|
|
50
52
|
|
|
51
53
|
class RecordingTransport:
|
|
@@ -87,6 +89,7 @@ class RecordingTransport:
|
|
|
87
89
|
credentials=delivery.credentials,
|
|
88
90
|
local_hostname=delivery.local_hostname,
|
|
89
91
|
starttls_verify=delivery.starttls_verify,
|
|
92
|
+
deadline=delivery.deadline,
|
|
90
93
|
)
|
|
91
94
|
)
|
|
92
95
|
|
|
@@ -1346,6 +1349,55 @@ def test_starttls_verify_and_local_hostname_from_the_file_reach_the_transport()
|
|
|
1346
1349
|
assert transport.deliveries[0].starttls_verify is False
|
|
1347
1350
|
|
|
1348
1351
|
|
|
1352
|
+
@pytest.mark.os_agnostic
|
|
1353
|
+
def test_delivery_deadline_from_the_file_reaches_the_transport() -> None:
|
|
1354
|
+
config = load_email_config_from_dict(
|
|
1355
|
+
{"email": {"smtp_hosts": ["smtp.test.com:587"], "from_address": "app@example.com", "delivery_deadline": 90}}
|
|
1356
|
+
)
|
|
1357
|
+
transport = RecordingTransport()
|
|
1358
|
+
|
|
1359
|
+
send_email(config=config, recipients="ops@example.com", subject="s", transport=transport)
|
|
1360
|
+
|
|
1361
|
+
assert transport.deliveries[0].deadline == 90.0
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
@pytest.mark.os_agnostic
|
|
1365
|
+
def test_recipient_max_count_from_the_file_refuses_a_larger_send_before_any_delivery() -> None:
|
|
1366
|
+
config = load_email_config_from_dict(
|
|
1367
|
+
{"email": {"smtp_hosts": ["smtp.test.com:587"], "from_address": "app@example.com", "recipient_max_count": 1}}
|
|
1368
|
+
)
|
|
1369
|
+
transport = RecordingTransport()
|
|
1370
|
+
|
|
1371
|
+
with pytest.raises(ValueError, match=r"2 recipients, more than recipient_max_count \(1\)"):
|
|
1372
|
+
send_email(config=config, recipients=["a@example.com", "b@example.com"], subject="s", transport=transport)
|
|
1373
|
+
|
|
1374
|
+
assert transport.attempted_hosts == []
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
@pytest.mark.os_agnostic
|
|
1378
|
+
def test_attachments_max_count_from_the_file_refuses_a_larger_send_before_any_delivery(tmp_path: Path) -> None:
|
|
1379
|
+
first, second = tmp_path / "a.txt", tmp_path / "b.txt"
|
|
1380
|
+
first.write_text("a", encoding="utf-8")
|
|
1381
|
+
second.write_text("b", encoding="utf-8")
|
|
1382
|
+
config = load_email_config_from_dict(
|
|
1383
|
+
{
|
|
1384
|
+
"email": {
|
|
1385
|
+
"smtp_hosts": ["smtp.test.com:587"],
|
|
1386
|
+
"from_address": "app@example.com",
|
|
1387
|
+
"attachments": {"max_count": 1},
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
)
|
|
1391
|
+
transport = RecordingTransport()
|
|
1392
|
+
|
|
1393
|
+
with pytest.raises(ValueError, match=r"2 attachments, more than attachment_max_count \(1\)"):
|
|
1394
|
+
send_email(
|
|
1395
|
+
config=config, recipients="ops@example.com", subject="s", attachments=[first, second], transport=transport
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
assert transport.attempted_hosts == []
|
|
1399
|
+
|
|
1400
|
+
|
|
1349
1401
|
_SHIPPED_MAIL = (
|
|
1350
1402
|
Path(__file__).parent.parent / "src" / "bitranox_template_py_cli" / "adapters" / "config" / "defaultconfig.d"
|
|
1351
1403
|
) / "50-mail.toml"
|
|
@@ -1356,3 +1408,18 @@ def test_the_shipped_defaults_verify_the_certificate_and_name_no_ehlo_host() ->
|
|
|
1356
1408
|
shipped = rtoml.load(_SHIPPED_MAIL)["email"]
|
|
1357
1409
|
assert shipped["starttls_verify"] is True
|
|
1358
1410
|
assert shipped["local_hostname"] == ""
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
@pytest.mark.os_agnostic
|
|
1414
|
+
def test_the_shipped_count_limits_and_deadline_load_as_the_library_defaults() -> None:
|
|
1415
|
+
shipped = rtoml.load(_SHIPPED_MAIL)
|
|
1416
|
+
# The keys are written out, so a reader of the shipped file sees them: the premise.
|
|
1417
|
+
assert "recipient_max_count" in shipped["email"]
|
|
1418
|
+
assert "delivery_deadline" in shipped["email"]
|
|
1419
|
+
assert "max_count" in shipped["email"]["attachments"]
|
|
1420
|
+
loaded = load_email_config_from_dict(shipped)
|
|
1421
|
+
library = ConfMail()
|
|
1422
|
+
|
|
1423
|
+
assert loaded.recipient_max_count == library.recipient_max_count
|
|
1424
|
+
assert loaded.attachment_max_count == library.attachment_max_count
|
|
1425
|
+
assert loaded.smtp_delivery_deadline is library.smtp_delivery_deadline is None
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_memory_logging.py
RENAMED
|
@@ -11,6 +11,7 @@ reset within one test instead of relying on test order.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
import logging
|
|
14
|
+
import os
|
|
14
15
|
from pathlib import Path
|
|
15
16
|
from typing import TYPE_CHECKING, Any
|
|
16
17
|
|
|
@@ -71,6 +72,30 @@ def test_the_reset_restores_the_root_logger_a_production_init_changed(
|
|
|
71
72
|
assert (list(root.handlers), root.level, root.propagate) == before
|
|
72
73
|
|
|
73
74
|
|
|
75
|
+
@pytest.mark.os_agnostic
|
|
76
|
+
def test_a_refused_log_variable_in_the_shell_does_not_break_build_testing(
|
|
77
|
+
cli_runner: CliRunner, monkeypatch: pytest.MonkeyPatch
|
|
78
|
+
) -> None:
|
|
79
|
+
"""lib_log_rich reads the ``LOG_*`` variables on every init, so a developer's own
|
|
80
|
+
``LOG_CONSOLE_LEVEL=bogus`` would refuse the test runtime and fail every command."""
|
|
81
|
+
monkeypatch.setenv("LOG_CONSOLE_LEVEL", "bogus")
|
|
82
|
+
|
|
83
|
+
result = cli_runner.invoke(cli_mod.cli, ["info"], obj=build_testing)
|
|
84
|
+
|
|
85
|
+
assert result.exception is None, result.exception
|
|
86
|
+
assert result.exit_code == 0, result.output
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.os_agnostic
|
|
90
|
+
def test_the_testing_runtime_puts_the_log_variables_back(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
91
|
+
monkeypatch.setenv("LOG_CONSOLE_LEVEL", "bogus")
|
|
92
|
+
|
|
93
|
+
build_testing().init_logging(Config({}, {}))
|
|
94
|
+
|
|
95
|
+
assert lib_log_rich.runtime.is_initialised() is True
|
|
96
|
+
assert os.environ.get("LOG_CONSOLE_LEVEL") == "bogus"
|
|
97
|
+
|
|
98
|
+
|
|
74
99
|
def _deploy_nothing(**_kwargs: Any) -> list[Path]:
|
|
75
100
|
return []
|
|
76
101
|
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.devcontainer/devcontainer.json
RENAMED
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.devcontainer/settings.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/.github/workflows/codeql.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/notebooks/Quickstart.ipynb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cache_effectiveness.py
RENAMED
|
File without changes
|
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_config_errors.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_env_file.py
RENAMED
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_exit_codes.py
RENAMED
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_main_exit.py
RENAMED
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_overrides.py
RENAMED
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_cli_validation.py
RENAMED
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_config_overrides.py
RENAMED
|
File without changes
|
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_deploy_mode_safety.py
RENAMED
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_deploy_permissions.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_metadata_sync.py
RENAMED
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_module_entry.py
RENAMED
|
File without changes
|
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_permission_defaults.py
RENAMED
|
File without changes
|
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_property_email.py
RENAMED
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_property_overrides.py
RENAMED
|
File without changes
|
{bitranox_template_py_cli-2.1.0 → bitranox_template_py_cli-2.1.1}/tests/test_safe_console.py
RENAMED
|
File without changes
|