bitranox-template-py-cli 2.0.1__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.0.1 → bitranox_template_py_cli-2.1.1}/.env.example +14 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/CHANGELOG.md +42 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/PKG-INFO +5 -4
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/README.md +3 -2
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/docs/systemdesign/module_reference.md +12 -8
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/pyproject.toml +2 -2
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
- {bitranox_template_py_cli-2.0.1 → 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.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/config_load.py +9 -5
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +28 -3
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/config.py +10 -14
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/transport.py +5 -3
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/setup.py +62 -14
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/logging.py +16 -11
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/ports.py +2 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_email_config_translation.py +51 -3
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_logging_dotenv_isolation.py +129 -2
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_mail.py +67 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_memory_logging.py +25 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.devcontainer/devcontainer.json +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.devcontainer/settings.json +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.gitattributes +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.github/actions/extract-metadata/action.yml +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.github/dependabot.yml +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.github/workflows/codeql.yml +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.github/workflows/default_cicd_public.yml +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.github/workflows/default_release_public.yml +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.gitignore +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.qlty/qlty.toml +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/.snyk +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/CONFIG.md +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/CONTRIBUTING.md +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/DEVELOPMENT.md +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/INSTALL.md +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/LICENSE +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/Makefile +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/SECURITY.md +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/ai-stance.md +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/ai-transparency.md +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/codecov.yml +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/docs/adr/0001-memory-adapters-in-src.md +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/notebooks/Quickstart.ipynb +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/rename.sh +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/rename_dry.sh +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/reset_git_history.sh +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__init__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/__main__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/config.py +0 -0
- {bitranox_template_py_cli-2.0.1 → 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.0.1 → 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.0.1 → 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.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/safe_console.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/cli/typed_click.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → 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.0.1 → 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.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/overrides.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/__init__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/memory/email.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/__init__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/application/py.typed +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/composition/__init__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/composition/py.typed +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/enums.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/errors.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/domain/py.typed +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/entry.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/src/bitranox_template_py_cli/py.typed +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/conftest.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_behaviors.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cache_effectiveness.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_config.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_config_errors.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_core.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_email.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_email_config_errors.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_env_file.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_exit_codes.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_main_exit.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_overrides.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_cli_validation.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_config_overrides.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_declared_dependencies.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_deploy_mode_safety.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_deploy_permissions.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_display.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_email_attachment_lists.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_email_password_secrecy.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_email_shipped_defaults.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_enums.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_errors.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_logging.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_metadata.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_metadata_sync.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_module_entry.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_module_reference_sync.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_permission_defaults.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_ports.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_property_email.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_property_overrides.py +0 -0
- {bitranox_template_py_cli-2.0.1 → bitranox_template_py_cli-2.1.1}/tests/test_safe_console.py +0 -0
|
@@ -101,6 +101,20 @@ GITHUB_TOKEN=
|
|
|
101
101
|
# .env: EMAIL__LOCAL_HOSTNAME=mail.example.com
|
|
102
102
|
# EMAIL__LOCAL_HOSTNAME=mail.example.com
|
|
103
103
|
|
|
104
|
+
# Purpose: Upper bound in seconds for one SMTP session (connect to QUIT); 0 for none
|
|
105
|
+
# Type: float
|
|
106
|
+
# Default: 0 (none)
|
|
107
|
+
# Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__DELIVERY_DEADLINE=300
|
|
108
|
+
# .env: EMAIL__DELIVERY_DEADLINE=300
|
|
109
|
+
# EMAIL__DELIVERY_DEADLINE=300
|
|
110
|
+
|
|
111
|
+
# Purpose: Most recipients one send accepts; 0 for no limit
|
|
112
|
+
# Type: integer
|
|
113
|
+
# Default: 1000
|
|
114
|
+
# Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__RECIPIENT_MAX_COUNT=1000
|
|
115
|
+
# .env: EMAIL__RECIPIENT_MAX_COUNT=1000
|
|
116
|
+
# EMAIL__RECIPIENT_MAX_COUNT=1000
|
|
117
|
+
|
|
104
118
|
# Purpose: Raise FileNotFoundError when attachment files are missing
|
|
105
119
|
# Type: boolean (true/false)
|
|
106
120
|
# Default: true
|
|
@@ -6,6 +6,48 @@ 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
|
+
|
|
30
|
+
## [2.1.0] 2026-10-06 15:37:27
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- **Three email limits from btx_lib_mail 4.0.0 are configurable**: `[email] delivery_deadline`
|
|
34
|
+
(an upper bound in seconds for one SMTP session; the field is `smtp_delivery_deadline`),
|
|
35
|
+
`[email] recipient_max_count` (default 1000) and `[email.attachments] max_count` (default
|
|
36
|
+
100). A value of 0 means no limit, as `max_size_bytes = 0` already did. Shipped in
|
|
37
|
+
`50-mail.toml`, `.env.example` and the module reference.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
- **Requires `btx_lib_mail>=4.0.0`.** Effects a user of the email commands can see: the
|
|
41
|
+
Windows dangerous extensions (`.exe`, `.bat`, `.ps1`, ...) are refused on every platform by
|
|
42
|
+
default and the POSIX ones on Windows; a sender or recipient longer than RFC 5321 allows is
|
|
43
|
+
refused; a subject with a control character or over 4096 characters is refused; an
|
|
44
|
+
attachment swapped after its checks is refused; a delivery error is a `DeliveryError` (still
|
|
45
|
+
a `RuntimeError`). See the btx_lib_mail 4.0.0 changelog for the full list.
|
|
46
|
+
|
|
47
|
+
### Removed
|
|
48
|
+
- **`EmailConfig`'s own SMTP host validator.** `ConfMail` checks every host's syntax (port
|
|
49
|
+
range, IPv6 brackets) itself since btx_lib_mail 3.0.1; the messages are unchanged.
|
|
50
|
+
|
|
9
51
|
## [2.0.1] 2026-10-06 13:09:50
|
|
10
52
|
|
|
11
53
|
Re-release of 2.0.0, which was tagged but never published: PyPI rejected the upload because
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: bitranox_template_py_cli
|
|
3
|
-
Version: 2.
|
|
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
|
|
@@ -21,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.14
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
|
-
Requires-Dist: btx-lib-mail>=
|
|
24
|
+
Requires-Dist: btx-lib-mail>=4.0.0
|
|
25
25
|
Requires-Dist: click>=8.5.0
|
|
26
26
|
Requires-Dist: lib-cli-exit-tools>=2.4.0
|
|
27
27
|
Requires-Dist: lib-layered-config>=7.0.1
|
|
@@ -286,6 +286,7 @@ use_starttls = true
|
|
|
286
286
|
starttls_verify = true # false accepts any certificate: only for a known internal relay
|
|
287
287
|
timeout = 60.0
|
|
288
288
|
# local_hostname = "mail.example.com" # EHLO name; set it where reverse DNS is slow
|
|
289
|
+
# delivery_deadline = 300 # upper bound in seconds for one SMTP session; 0 = none
|
|
289
290
|
```
|
|
290
291
|
|
|
291
292
|
A key `[email]` or `[email.attachments]` does not know is refused (exit 78,
|
|
@@ -378,8 +379,8 @@ from bitranox_template_py_cli.composition import send_email, send_notification
|
|
|
378
379
|
|
|
379
380
|
# Configure email. EmailConfig is a btx_lib_mail ConfMail, so in Python the fields use the
|
|
380
381
|
# library's names (smtphosts, smtp_use_starttls, smtp_timeout, smtp_starttls_verify,
|
|
381
|
-
# smtp_local_hostname); configuration files keep their keys (smtp_hosts,
|
|
382
|
-
# starttls_verify, local_hostname). smtp_password is a SecretStr: it prints as
|
|
382
|
+
# smtp_local_hostname, smtp_delivery_deadline); configuration files keep their keys (smtp_hosts,
|
|
383
|
+
# use_starttls, timeout, starttls_verify, local_hostname, delivery_deadline). smtp_password is a SecretStr: it prints as
|
|
383
384
|
# '**********' in logs, reprs and dumps, and config.smtp_password.get_secret_value() returns
|
|
384
385
|
# the plain text.
|
|
385
386
|
config = EmailConfig(
|
|
@@ -231,6 +231,7 @@ use_starttls = true
|
|
|
231
231
|
starttls_verify = true # false accepts any certificate: only for a known internal relay
|
|
232
232
|
timeout = 60.0
|
|
233
233
|
# local_hostname = "mail.example.com" # EHLO name; set it where reverse DNS is slow
|
|
234
|
+
# delivery_deadline = 300 # upper bound in seconds for one SMTP session; 0 = none
|
|
234
235
|
```
|
|
235
236
|
|
|
236
237
|
A key `[email]` or `[email.attachments]` does not know is refused (exit 78,
|
|
@@ -323,8 +324,8 @@ from bitranox_template_py_cli.composition import send_email, send_notification
|
|
|
323
324
|
|
|
324
325
|
# Configure email. EmailConfig is a btx_lib_mail ConfMail, so in Python the fields use the
|
|
325
326
|
# library's names (smtphosts, smtp_use_starttls, smtp_timeout, smtp_starttls_verify,
|
|
326
|
-
# smtp_local_hostname); configuration files keep their keys (smtp_hosts,
|
|
327
|
-
# starttls_verify, local_hostname). smtp_password is a SecretStr: it prints as
|
|
327
|
+
# smtp_local_hostname, smtp_delivery_deadline); configuration files keep their keys (smtp_hosts,
|
|
328
|
+
# use_starttls, timeout, starttls_verify, local_hostname, delivery_deadline). smtp_password is a SecretStr: it prints as
|
|
328
329
|
# '**********' in logs, reprs and dumps, and config.smtp_password.get_secret_value() returns
|
|
329
330
|
# the plain text.
|
|
330
331
|
config = EmailConfig(
|
|
@@ -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
|
|--------------------------------|---------------------|---------|--------------------------------|------------------------------------------------------------------|
|
|
@@ -337,6 +338,8 @@ column).
|
|
|
337
338
|
| `smtp_starttls_verify` | `bool` | `True` | `starttls_verify` | Verify the server certificate after STARTTLS |
|
|
338
339
|
| `smtp_timeout` | `float` | `30.0` | `timeout` | Socket timeout in seconds |
|
|
339
340
|
| `smtp_local_hostname` | `str \| None` | `None` | `local_hostname` | Host name announced in EHLO; `None` looks it up once per process |
|
|
341
|
+
| `smtp_delivery_deadline` | `float \| None` | `None` | `delivery_deadline` | Upper bound in seconds for one SMTP session; `None` for none |
|
|
342
|
+
| `recipient_max_count` | `int \| None` | `1000` | `recipient_max_count` | Most recipients one send accepts; `None` lifts the limit |
|
|
340
343
|
| `raise_on_missing_attachments` | `bool` | `True` | `raise_on_missing_attachments` | Raise on missing attachment files |
|
|
341
344
|
| `raise_on_invalid_recipient` | `bool` | `True` | `raise_on_invalid_recipient` | Raise on invalid recipient addresses |
|
|
342
345
|
|
|
@@ -345,10 +348,11 @@ column).
|
|
|
345
348
|
| Field | Type | Default | File key (`[email.attachments]`) | Description |
|
|
346
349
|
|------------------------------------------|---------------------------|--------------|----------------------------------|---------------------------------------------------------|
|
|
347
350
|
| `attachment_allowed_extensions` | `frozenset[str] \| None` | `None` | `allowed_extensions` | Whitelist of allowed extensions |
|
|
348
|
-
| `attachment_blocked_extensions` | `frozenset[str]` |
|
|
351
|
+
| `attachment_blocked_extensions` | `frozenset[str]` | POSIX + Win | `blocked_extensions` | Blacklist of blocked extensions (both lists, every OS) |
|
|
349
352
|
| `attachment_allowed_directories` | `frozenset[Path] \| None` | `None` | `allowed_directories` | Whitelist of allowed source directories |
|
|
350
353
|
| `attachment_blocked_directories` | `frozenset[Path]` | OS defaults | `blocked_directories` | Blacklist of blocked directories |
|
|
351
354
|
| `attachment_max_size_bytes` | `int \| None` | `26_214_400` | `max_size_bytes` | Maximum file size (25 MiB), `None` to disable |
|
|
355
|
+
| `attachment_max_count` | `int \| None` | `100` | `max_count` | Most attachments one send accepts, `None` to disable |
|
|
352
356
|
| `attachment_allow_symlinks` | `bool` | `False` | `allow_symlinks` | Whether symlinks are permitted |
|
|
353
357
|
| `attachment_raise_on_security_violation` | `bool` | `True` | `raise_on_security_violation` | Raise or skip on security violation |
|
|
354
358
|
| `attachment_allow_empty_blocklists` | `bool` | `False` | not exposed | Allow an empty blocked set (blocks nothing) from Python |
|
|
@@ -359,13 +363,13 @@ lib_layered_config is the only reader of configuration: it merges every layer (t
|
|
|
359
363
|
defaults, the app, host and user files, `.env`, the environment, `--set`) into one mapping.
|
|
360
364
|
`load_email_config_from_dict()` turns that mapping's `[email]` section into an `EmailConfig`:
|
|
361
365
|
|
|
362
|
-
- the
|
|
366
|
+
- the six keys in the fourth column that differ from the field names are mapped;
|
|
363
367
|
- `[email.attachments]` keys become `attachment_<key>`;
|
|
364
368
|
- a key that is not listed is refused (exit 78, `email.<key>: unknown key`), whatever layer it came from;
|
|
365
369
|
- blank text means "not configured"; a single host or address string is a one-entry list;
|
|
366
370
|
- an empty attachment list (`[]` or a blank string) means the library's defaults, and any other
|
|
367
371
|
value that is not a list, such as a comma-separated string, is refused, as is a blank entry;
|
|
368
|
-
- `max_size_bytes
|
|
372
|
+
- a limit of 0 (`max_size_bytes`, `max_count`, `recipient_max_count`, `delivery_deadline`) means no limit.
|
|
369
373
|
|
|
370
374
|
`describe_validation_error()` renders a refusal as one `email.<file key>: <reason>` line per
|
|
371
375
|
problem, never with the refused value.
|
|
@@ -376,7 +380,7 @@ problem, never with the refused value.
|
|
|
376
380
|
|---------------------|------------------------------------------------------------------------------|
|
|
377
381
|
| `SECTION_KEYS` | Every key `[email]` accepts (the `attachments` table included) |
|
|
378
382
|
| `ATTACHMENT_KEYS` | Every key `[email.attachments]` accepts; each names field `attachment_<key>` |
|
|
379
|
-
| `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 |
|
|
380
384
|
|
|
381
385
|
---
|
|
382
386
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "bitranox_template_py_cli"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.1.1"
|
|
4
4
|
description = "Template CLI application with configuration management and structured logging"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
@@ -12,7 +12,7 @@ dependencies = [
|
|
|
12
12
|
"lib_log_rich>=6.3.9",
|
|
13
13
|
"python-dotenv>=1.2.4",
|
|
14
14
|
"lib_layered_config>=7.0.1",
|
|
15
|
-
"btx_lib_mail>=
|
|
15
|
+
"btx_lib_mail>=4.0.0",
|
|
16
16
|
"pydantic>=2.13.5",
|
|
17
17
|
"orjson>=3.12.0",
|
|
18
18
|
]
|
|
@@ -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.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
|
|
|
@@ -110,6 +110,23 @@ starttls_verify = true
|
|
|
110
110
|
# qualified name here.
|
|
111
111
|
local_hostname = ""
|
|
112
112
|
|
|
113
|
+
# Purpose: Upper bound in seconds for one SMTP session, from the connect to QUIT
|
|
114
|
+
# Type: float, or 0 for no deadline
|
|
115
|
+
# Default: 0 (none; only the socket timeout bounds each read or write)
|
|
116
|
+
# Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__DELIVERY_DEADLINE=300
|
|
117
|
+
# .env: EMAIL__DELIVERY_DEADLINE=300
|
|
118
|
+
# Note: The socket timeout restarts with every byte, so a server answering one byte at a
|
|
119
|
+
# time can hold a session open indefinitely; past the deadline that host counts as failed.
|
|
120
|
+
delivery_deadline = 0
|
|
121
|
+
|
|
122
|
+
# Purpose: Most recipients one send accepts (counted after duplicates are dropped)
|
|
123
|
+
# Type: integer, or 0 for no limit
|
|
124
|
+
# Default: 1000
|
|
125
|
+
# Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__RECIPIENT_MAX_COUNT=1000
|
|
126
|
+
# .env: EMAIL__RECIPIENT_MAX_COUNT=1000
|
|
127
|
+
# Note: More recipients are refused before anything is delivered
|
|
128
|
+
recipient_max_count = 1000
|
|
129
|
+
|
|
113
130
|
# Purpose: Behavior when attachment files are missing
|
|
114
131
|
# Type: boolean
|
|
115
132
|
# Default: true (raise FileNotFoundError)
|
|
@@ -152,9 +169,9 @@ allowed_extensions = []
|
|
|
152
169
|
|
|
153
170
|
# Purpose: Blacklist of blocked file extensions (ignored when allowed_extensions is set)
|
|
154
171
|
# Type: array of strings
|
|
155
|
-
# Default: [] (the library's defaults apply: btx_lib_mail DANGEROUS_EXTENSIONS_POSIX
|
|
156
|
-
# DANGEROUS_EXTENSIONS_WINDOWS; paths matching SENSITIVE_PATH_PATTERNS such
|
|
157
|
-
# always refused)
|
|
172
|
+
# Default: [] (the library's defaults apply: btx_lib_mail DANGEROUS_EXTENSIONS_POSIX plus
|
|
173
|
+
# DANGEROUS_EXTENSIONS_WINDOWS on every platform; paths matching SENSITIVE_PATH_PATTERNS such
|
|
174
|
+
# as /.ssh/ are always refused)
|
|
158
175
|
# Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__ATTACHMENTS__BLOCKED_EXTENSIONS='[".exe",".bat",".cmd"]'
|
|
159
176
|
# .env: EMAIL__ATTACHMENTS__BLOCKED_EXTENSIONS=[".exe",".bat",".cmd"] (unquoted)
|
|
160
177
|
# A comma-separated value is NOT a list: it arrives as one string and is refused
|
|
@@ -197,6 +214,14 @@ blocked_directories = []
|
|
|
197
214
|
# Note: Set to 0 to disable size checking entirely
|
|
198
215
|
max_size_bytes = 26214400
|
|
199
216
|
|
|
217
|
+
# Purpose: Most attachments one send accepts
|
|
218
|
+
# Type: integer, or 0 for no limit
|
|
219
|
+
# Default: 100
|
|
220
|
+
# Environment Variable: BITRANOX_TEMPLATE_PY_CLI___EMAIL__ATTACHMENTS__MAX_COUNT=100
|
|
221
|
+
# .env: EMAIL__ATTACHMENTS__MAX_COUNT=100
|
|
222
|
+
# Note: More attachments are refused before any file is checked or anything is delivered
|
|
223
|
+
max_count = 100
|
|
224
|
+
|
|
200
225
|
# Purpose: Whether to allow symbolic links as attachments
|
|
201
226
|
# Type: boolean
|
|
202
227
|
# Default: false (symlinks are rejected)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
lib_layered_config is the only reader of configuration. It merges every layer (the shipped
|
|
4
4
|
defaults, the app, host and user files, ``.env``, the environment and ``--set``) into one
|
|
5
5
|
mapping, and :func:`load_email_config_from_dict` turns that mapping's ``[email]`` section into
|
|
6
|
-
:class:`EmailConfig`. Operators keep writing the file keys they always wrote;
|
|
6
|
+
:class:`EmailConfig`. Operators keep writing the file keys they always wrote; six of them
|
|
7
7
|
differ from the field names code reads (``FILE_KEY_TO_FIELD``). A key that is not a file key
|
|
8
8
|
is refused, so a typo cannot leave a setting silently at its default.
|
|
9
9
|
"""
|
|
@@ -14,7 +14,7 @@ import re
|
|
|
14
14
|
from collections.abc import Mapping
|
|
15
15
|
from typing import TYPE_CHECKING, Any, cast
|
|
16
16
|
|
|
17
|
-
from btx_lib_mail import ConfMail, validate_email_address
|
|
17
|
+
from btx_lib_mail import ConfMail, validate_email_address
|
|
18
18
|
from pydantic import ConfigDict, Field, SecretStr, ValidationError, field_validator
|
|
19
19
|
|
|
20
20
|
if TYPE_CHECKING:
|
|
@@ -34,6 +34,7 @@ FILE_KEY_TO_FIELD: dict[str, str] = {
|
|
|
34
34
|
"timeout": "smtp_timeout",
|
|
35
35
|
"starttls_verify": "smtp_starttls_verify",
|
|
36
36
|
"local_hostname": "smtp_local_hostname",
|
|
37
|
+
"delivery_deadline": "smtp_delivery_deadline",
|
|
37
38
|
}
|
|
38
39
|
_FIELD_TO_FILE_KEY: dict[str, str] = {field: key for key, field in FILE_KEY_TO_FIELD.items()}
|
|
39
40
|
|
|
@@ -47,6 +48,7 @@ SECTION_KEYS = frozenset(
|
|
|
47
48
|
"smtp_password",
|
|
48
49
|
"raise_on_missing_attachments",
|
|
49
50
|
"raise_on_invalid_recipient",
|
|
51
|
+
"recipient_max_count",
|
|
50
52
|
_ATTACHMENTS,
|
|
51
53
|
}
|
|
52
54
|
)
|
|
@@ -59,6 +61,7 @@ ATTACHMENT_KEYS = frozenset(
|
|
|
59
61
|
"allowed_directories",
|
|
60
62
|
"blocked_directories",
|
|
61
63
|
"max_size_bytes",
|
|
64
|
+
"max_count",
|
|
62
65
|
"allow_symlinks",
|
|
63
66
|
"raise_on_security_violation",
|
|
64
67
|
}
|
|
@@ -74,6 +77,9 @@ _BLANK_TEXT_MEANS_UNSET = frozenset(
|
|
|
74
77
|
_EMPTY_LIST_MEANS_DEFAULT = frozenset(
|
|
75
78
|
{"allowed_extensions", "blocked_extensions", "allowed_directories", "blocked_directories"}
|
|
76
79
|
)
|
|
80
|
+
#: Limits whose 0 means "no limit". ConfMail refuses 0 and lifts a limit with None, which a
|
|
81
|
+
#: TOML file or an environment variable cannot write.
|
|
82
|
+
_ZERO_MEANS_NO_LIMIT = frozenset({"max_size_bytes", "max_count", "recipient_max_count", "delivery_deadline"})
|
|
77
83
|
#: The refusal of a blank entry in an attachment list.
|
|
78
84
|
_BLANK_ENTRY = ValueError("an entry is blank; remove it")
|
|
79
85
|
#: The form an attachment list must take, named by the refusal of any other form. A
|
|
@@ -146,16 +152,6 @@ class EmailConfig(ConfMail):
|
|
|
146
152
|
return [value] if value.strip() else []
|
|
147
153
|
return value
|
|
148
154
|
|
|
149
|
-
@field_validator("smtphosts")
|
|
150
|
-
@classmethod
|
|
151
|
-
def _check_hosts(cls, value: list[str]) -> list[str]:
|
|
152
|
-
# ConfMail refuses userinfo, a path and control characters; the port range and the
|
|
153
|
-
# IPv6 brackets are checked only by validate_smtp_host, so a typo surfaces at load
|
|
154
|
-
# time rather than at the first delivery.
|
|
155
|
-
for host in value:
|
|
156
|
-
validate_smtp_host(host)
|
|
157
|
-
return value
|
|
158
|
-
|
|
159
155
|
@field_validator("from_address")
|
|
160
156
|
@classmethod
|
|
161
157
|
def _check_from_address(cls, value: str | None) -> str | None:
|
|
@@ -273,10 +269,10 @@ def _means_unset(key: str, value: object) -> bool:
|
|
|
273
269
|
|
|
274
270
|
|
|
275
271
|
def _field_value(key: str, value: object) -> object:
|
|
276
|
-
"""A lone host or address becomes a one-entry list; a
|
|
272
|
+
"""A lone host or address becomes a one-entry list; a limit of 0 means no limit."""
|
|
277
273
|
if key in _ONE_OR_MANY and isinstance(value, str):
|
|
278
274
|
return [value]
|
|
279
|
-
if key
|
|
275
|
+
if key in _ZERO_MEANS_NO_LIMIT and type(value) in (int, float) and value == 0:
|
|
280
276
|
return None
|
|
281
277
|
return value
|
|
282
278
|
|
|
@@ -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: ...
|