bitranox-template-py-cli 1.5.0__tar.gz → 1.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/.github/workflows/default_release_public.yml +1 -1
  2. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/CHANGELOG.md +21 -0
  3. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/PKG-INFO +2 -2
  4. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/pyproject.toml +2 -2
  5. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/__init__conf__.py +1 -1
  6. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/commands/config.py +1 -1
  7. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/config/permissions.py +55 -25
  8. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/memory/email.py +44 -21
  9. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/conftest.py +1 -1
  10. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_cli_email.py +15 -15
  11. bitranox_template_py_cli-1.5.2/tests/test_cli_env_file.py +177 -0
  12. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_deploy_permissions.py +13 -13
  13. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/.devcontainer/devcontainer.json +0 -0
  14. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/.devcontainer/settings.json +0 -0
  15. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/.env.example +0 -0
  16. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/.github/actions/extract-metadata/action.yml +0 -0
  17. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/.github/dependabot.yml +0 -0
  18. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/.github/workflows/codeql.yml +0 -0
  19. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/.github/workflows/default_cicd_public.yml +0 -0
  20. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/.gitignore +0 -0
  21. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/.qlty/qlty.toml +0 -0
  22. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/.snyk +0 -0
  23. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/CONFIG.md +0 -0
  24. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/CONTRIBUTING.md +0 -0
  25. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/DEVELOPMENT.md +0 -0
  26. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/INSTALL.md +0 -0
  27. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/LICENSE +0 -0
  28. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/Makefile +0 -0
  29. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/README.md +0 -0
  30. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/SECURITY.md +0 -0
  31. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/codecov.yml +0 -0
  32. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/docs/adr/0001-memory-adapters-in-src.md +0 -0
  33. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/docs/systemdesign/module_reference.md +0 -0
  34. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/notebooks/Quickstart.ipynb +0 -0
  35. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/rename.sh +0 -0
  36. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/rename_dry.sh +0 -0
  37. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/reset_git_history.sh +0 -0
  38. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/__init__.py +0 -0
  39. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/__main__.py +0 -0
  40. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/__init__.py +0 -0
  41. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/__init__.py +0 -0
  42. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/commands/__init__.py +0 -0
  43. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/__init__.py +0 -0
  44. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/_common.py +0 -0
  45. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_email.py +0 -0
  46. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/commands/email/send_notification.py +0 -0
  47. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/commands/info.py +0 -0
  48. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/commands/logging.py +0 -0
  49. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/constants.py +0 -0
  50. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/context.py +0 -0
  51. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/exit_codes.py +0 -0
  52. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/main.py +0 -0
  53. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/py.typed +0 -0
  54. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/cli/root.py +0 -0
  55. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/config/__init__.py +0 -0
  56. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/40-layered-config.toml +0 -0
  57. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/50-mail.toml +0 -0
  58. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.d/90-logging.toml +0 -0
  59. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/config/defaultconfig.toml +0 -0
  60. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/config/deploy.py +0 -0
  61. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/config/display.py +0 -0
  62. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/config/loader.py +0 -0
  63. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/config/overrides.py +0 -0
  64. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/config/py.typed +0 -0
  65. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/email/__init__.py +0 -0
  66. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/email/config.py +0 -0
  67. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/email/py.typed +0 -0
  68. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/email/sender.py +0 -0
  69. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/email/transport.py +0 -0
  70. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/email/validation.py +0 -0
  71. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/logging/__init__.py +0 -0
  72. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/logging/py.typed +0 -0
  73. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/logging/setup.py +0 -0
  74. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/memory/__init__.py +0 -0
  75. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/memory/config.py +0 -0
  76. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/memory/logging.py +0 -0
  77. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/adapters/py.typed +0 -0
  78. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/application/__init__.py +0 -0
  79. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/application/ports.py +0 -0
  80. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/application/py.typed +0 -0
  81. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/composition/__init__.py +0 -0
  82. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/composition/py.typed +0 -0
  83. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/domain/__init__.py +0 -0
  84. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/domain/behaviors.py +0 -0
  85. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/domain/enums.py +0 -0
  86. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/domain/errors.py +0 -0
  87. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/domain/py.typed +0 -0
  88. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/entry.py +0 -0
  89. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/src/bitranox_template_py_cli/py.typed +0 -0
  90. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_behaviors.py +0 -0
  91. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_cache_effectiveness.py +0 -0
  92. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_cli_config.py +0 -0
  93. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_cli_core.py +0 -0
  94. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_cli_exit_codes.py +0 -0
  95. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_cli_overrides.py +0 -0
  96. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_cli_validation.py +0 -0
  97. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_config_overrides.py +0 -0
  98. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_display.py +0 -0
  99. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_enums.py +0 -0
  100. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_errors.py +0 -0
  101. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_logging.py +0 -0
  102. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_mail.py +0 -0
  103. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_metadata.py +0 -0
  104. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_metadata_sync.py +0 -0
  105. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_module_entry.py +0 -0
  106. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_ports.py +0 -0
  107. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_property_email.py +0 -0
  108. {bitranox_template_py_cli-1.5.0 → bitranox_template_py_cli-1.5.2}/tests/test_property_overrides.py +0 -0
@@ -56,7 +56,7 @@ jobs:
56
56
  fi
57
57
 
58
58
  - name: Download artifacts
59
- uses: actions/download-artifact@v7
59
+ uses: actions/download-artifact@v8
60
60
  with:
61
61
  name: dist
62
62
  path: dist/
@@ -6,6 +6,27 @@ the [Keep a Changelog](https://keepachangelog.com/) format.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.5.2] - 2026-03-05
10
+
11
+ ### Fixed
12
+ - Re-release as 1.5.2: PyPI rejected 1.5.1 due to filename reuse after prior upload deletion
13
+
14
+ ### Changed
15
+ - `get_permission_defaults()` now returns a `PermissionDefaults` Pydantic model instead of a raw dict, with typed field access and `dir_mode_for()`/`file_mode_for()` methods
16
+ - `EmailSpy` now stores captured calls as `CapturedEmail` and `CapturedNotification` frozen dataclasses instead of `list[dict[str, Any]]`
17
+
18
+ ### Added
19
+ - Tests for `--env-file` CLI option (argument passing, validation, end-to-end integration)
20
+
21
+ ## [1.5.1] - 2026-03-05 [YANKED]
22
+
23
+ ### Changed
24
+ - `get_permission_defaults()` now returns a `PermissionDefaults` Pydantic model instead of a raw dict, with typed field access and `dir_mode_for()`/`file_mode_for()` methods
25
+ - `EmailSpy` now stores captured calls as `CapturedEmail` and `CapturedNotification` frozen dataclasses instead of `list[dict[str, Any]]`
26
+
27
+ ### Added
28
+ - Tests for `--env-file` CLI option (argument passing, validation, end-to-end integration)
29
+
9
30
  ## [1.5.0] - 2026-03-02
10
31
 
11
32
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bitranox_template_py_cli
3
- Version: 1.5.0
3
+ Version: 1.5.2
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
@@ -44,7 +44,7 @@ Requires-Dist: pytest>=9.0.2; extra == 'dev'
44
44
  Requires-Dist: python-multipart>=0.0.22; extra == 'dev'
45
45
  Requires-Dist: rtoml>=0.13.0; extra == 'dev'
46
46
  Requires-Dist: ruff>=0.15.4; extra == 'dev'
47
- Requires-Dist: textual>=8.0.1; extra == 'dev'
47
+ Requires-Dist: textual>=8.0.2; extra == 'dev'
48
48
  Requires-Dist: twine>=6.2.0; extra == 'dev'
49
49
  Requires-Dist: urllib3>=2.6.3; extra == 'dev'
50
50
  Requires-Dist: virtualenv>=21.1.0; extra == 'dev'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bitranox_template_py_cli"
3
- version = "1.5.0"
3
+ version = "1.5.2"
4
4
  description = "Template CLI application with configuration management and structured logging"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -47,7 +47,7 @@ dev = [
47
47
  "twine>=6.2.0",
48
48
  "codecov-cli>=11.2.6",
49
49
  "pip-audit>=2.10.0",
50
- "textual>=8.0.1",
50
+ "textual>=8.0.2",
51
51
  "import-linter>=2.10",
52
52
  "rtoml>=0.13.0",
53
53
  "httpx>=0.28.1",
@@ -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 = "1.5.0"
43
+ version = "1.5.2"
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.
@@ -247,7 +247,7 @@ def _execute_deploy(
247
247
  perm_defaults = get_permission_defaults(cli_ctx.config)
248
248
 
249
249
  # CLI --permissions/--no-permissions overrides config enabled setting
250
- effective_set_permissions = set_permissions if set_permissions is not None else bool(perm_defaults["enabled"])
250
+ effective_set_permissions = set_permissions if set_permissions is not None else perm_defaults.enabled
251
251
 
252
252
  try:
253
253
  deployed_paths = cli_ctx.services.deploy_configuration(
@@ -14,6 +14,7 @@ from lib_layered_config import (
14
14
  DEFAULT_USER_DIR_MODE,
15
15
  DEFAULT_USER_FILE_MODE,
16
16
  )
17
+ from pydantic import BaseModel, ConfigDict
17
18
 
18
19
  from bitranox_template_py_cli.domain.enums import DeployTarget
19
20
 
@@ -21,6 +22,37 @@ if TYPE_CHECKING:
21
22
  from lib_layered_config import Config
22
23
 
23
24
 
25
+ class PermissionDefaults(BaseModel):
26
+ """Validated, immutable permission defaults for deployment layers.
27
+
28
+ Parsed at the boundary from ``[lib_layered_config.default_permissions]``
29
+ config section. All fields have library-level fallback defaults.
30
+
31
+ Example:
32
+ >>> defaults = PermissionDefaults()
33
+ >>> defaults.user_directory == 0o700
34
+ True
35
+ """
36
+
37
+ model_config = ConfigDict(frozen=True)
38
+
39
+ app_directory: int = DEFAULT_APP_DIR_MODE
40
+ app_file: int = DEFAULT_APP_FILE_MODE
41
+ host_directory: int = DEFAULT_APP_DIR_MODE
42
+ host_file: int = DEFAULT_APP_FILE_MODE
43
+ user_directory: int = DEFAULT_USER_DIR_MODE
44
+ user_file: int = DEFAULT_USER_FILE_MODE
45
+ enabled: bool = True
46
+
47
+ def dir_mode_for(self, layer: str) -> int:
48
+ """Return directory mode for the given layer name."""
49
+ return getattr(self, f"{layer}_directory")
50
+
51
+ def file_mode_for(self, layer: str) -> int:
52
+ """Return file mode for the given layer name."""
53
+ return getattr(self, f"{layer}_file")
54
+
55
+
24
56
  def parse_mode(value: int | str, default: int) -> int:
25
57
  """Parse a permission mode value from config.
26
58
 
@@ -53,15 +85,20 @@ def parse_mode(value: int | str, default: int) -> int:
53
85
  return default
54
86
 
55
87
 
56
- def _get_mode(section: dict[str, int | str | bool], key: str, default: int) -> int:
57
- """Get a mode value from config section, parsing octal strings."""
88
+ def _parse_mode_from_section(section: dict[str, int | str | bool], key: str, default: int) -> int:
89
+ """Extract and parse a mode value from a raw config section dict.
90
+
91
+ Used only at the boundary when parsing the raw config dict into
92
+ PermissionDefaults. The raw section comes from lib_layered_config's
93
+ Config.get() which returns untyped dicts.
94
+ """
58
95
  raw = section.get(key, default)
59
96
  if isinstance(raw, bool):
60
97
  return default
61
98
  return parse_mode(raw, default)
62
99
 
63
100
 
64
- def get_permission_defaults(config: Config) -> dict[str, int | bool]:
101
+ def get_permission_defaults(config: Config) -> PermissionDefaults:
65
102
  """Load permission defaults from [lib_layered_config.default_permissions].
66
103
 
67
104
  Reads configurable permission defaults for each deployment layer.
@@ -71,35 +108,29 @@ def get_permission_defaults(config: Config) -> dict[str, int | bool]:
71
108
  config: Configuration object with merged settings.
72
109
 
73
110
  Returns:
74
- Dictionary with keys:
75
- - app_directory: Directory mode for app layer (default 0o755)
76
- - app_file: File mode for app layer (default 0o644)
77
- - host_directory: Directory mode for host layer (default 0o755)
78
- - host_file: File mode for host layer (default 0o644)
79
- - user_directory: Directory mode for user layer (default 0o700)
80
- - user_file: File mode for user layer (default 0o600)
81
- - enabled: Whether permission setting is enabled (default True)
111
+ PermissionDefaults model with typed fields for each layer's
112
+ directory and file modes, plus an enabled flag.
82
113
 
83
114
  Example:
84
115
  >>> from lib_layered_config import Config
85
116
  >>> config = Config({}, {}) # Empty config
86
117
  >>> defaults = get_permission_defaults(config)
87
- >>> defaults["user_directory"] == 0o700
118
+ >>> defaults.user_directory == 0o700
88
119
  True
89
120
  """
90
121
  section = config.get("lib_layered_config", {}).get("default_permissions", {})
91
122
  # NOTE: lib_layered_config does not define separate HOST_* constants.
92
123
  # Host layer shares defaults with app layer (both world-readable: 755/644).
93
124
  # This is intentional per CLAUDE.md "Deployment Permissions" documentation.
94
- return {
95
- "app_directory": _get_mode(section, "app_directory", DEFAULT_APP_DIR_MODE),
96
- "app_file": _get_mode(section, "app_file", DEFAULT_APP_FILE_MODE),
97
- "host_directory": _get_mode(section, "host_directory", DEFAULT_APP_DIR_MODE),
98
- "host_file": _get_mode(section, "host_file", DEFAULT_APP_FILE_MODE),
99
- "user_directory": _get_mode(section, "user_directory", DEFAULT_USER_DIR_MODE),
100
- "user_file": _get_mode(section, "user_file", DEFAULT_USER_FILE_MODE),
101
- "enabled": section.get("enabled", True),
102
- }
125
+ return PermissionDefaults(
126
+ app_directory=_parse_mode_from_section(section, "app_directory", DEFAULT_APP_DIR_MODE),
127
+ app_file=_parse_mode_from_section(section, "app_file", DEFAULT_APP_FILE_MODE),
128
+ host_directory=_parse_mode_from_section(section, "host_directory", DEFAULT_APP_DIR_MODE),
129
+ host_file=_parse_mode_from_section(section, "host_file", DEFAULT_APP_FILE_MODE),
130
+ user_directory=_parse_mode_from_section(section, "user_directory", DEFAULT_USER_DIR_MODE),
131
+ user_file=_parse_mode_from_section(section, "user_file", DEFAULT_USER_FILE_MODE),
132
+ enabled=section.get("enabled", True),
133
+ )
103
134
 
104
135
 
105
136
  def get_modes_for_target(
@@ -140,15 +171,14 @@ def get_modes_for_target(
140
171
  defaults = get_permission_defaults(config)
141
172
  layer = target.value # "app", "host", or "user"
142
173
 
143
- # defaults always contains int values for all layer keys (get_permission_defaults
144
- # uses lib_layered_config defaults as fallbacks), so cast is safe here.
145
- dir_mode: int = dir_mode_override if dir_mode_override is not None else int(defaults[f"{layer}_directory"])
146
- file_mode: int = file_mode_override if file_mode_override is not None else int(defaults[f"{layer}_file"])
174
+ dir_mode: int = dir_mode_override if dir_mode_override is not None else defaults.dir_mode_for(layer)
175
+ file_mode: int = file_mode_override if file_mode_override is not None else defaults.file_mode_for(layer)
147
176
 
148
177
  return dir_mode, file_mode
149
178
 
150
179
 
151
180
  __all__ = [
181
+ "PermissionDefaults",
152
182
  "get_modes_for_target",
153
183
  "get_permission_defaults",
154
184
  "parse_mode",
@@ -4,6 +4,8 @@ Provides email functions that satisfy the same Protocols as production
4
4
  adapters but perform no SMTP operations.
5
5
 
6
6
  Contents:
7
+ * :class:`CapturedEmail` - Typed record for captured send_email calls.
8
+ * :class:`CapturedNotification` - Typed record for captured send_notification calls.
7
9
  * :class:`EmailSpy` - Captures email calls for test assertions.
8
10
  * :func:`load_email_config_from_dict_in_memory` - In-memory config loader.
9
11
  """
@@ -19,9 +21,28 @@ from ..email.sender import EmailConfig
19
21
  from ..email.validation import validate_recipients
20
22
 
21
23
 
22
- def _empty_email_list() -> list[dict[str, Any]]:
23
- """Create an empty typed list for email records."""
24
- return []
24
+ @dataclass(frozen=True, slots=True)
25
+ class CapturedEmail:
26
+ """Typed record of a captured send_email call."""
27
+
28
+ config: EmailConfig
29
+ recipients: str | Sequence[str] | None
30
+ subject: str
31
+ body: str
32
+ body_html: str
33
+ from_address: str | None
34
+ attachments: list[Path] | None
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class CapturedNotification:
39
+ """Typed record of a captured send_notification call."""
40
+
41
+ config: EmailConfig
42
+ recipients: str | Sequence[str] | None
43
+ subject: str
44
+ message: str
45
+ from_address: str | None
25
46
 
26
47
 
27
48
  @dataclass
@@ -46,8 +67,8 @@ class EmailSpy:
46
67
  1
47
68
  """
48
69
 
49
- sent_emails: list[dict[str, Any]] = field(default_factory=_empty_email_list)
50
- sent_notifications: list[dict[str, Any]] = field(default_factory=_empty_email_list)
70
+ sent_emails: list[CapturedEmail] = field(default_factory=lambda: [])
71
+ sent_notifications: list[CapturedNotification] = field(default_factory=lambda: [])
51
72
  should_fail: bool = False
52
73
  raise_exception: Exception | None = None
53
74
 
@@ -88,15 +109,15 @@ class EmailSpy:
88
109
  """
89
110
  validate_recipients(recipients)
90
111
  self.sent_emails.append(
91
- {
92
- "config": config,
93
- "recipients": recipients,
94
- "subject": subject,
95
- "body": body,
96
- "body_html": body_html,
97
- "from_address": from_address,
98
- "attachments": list(attachments) if attachments else None,
99
- }
112
+ CapturedEmail(
113
+ config=config,
114
+ recipients=recipients,
115
+ subject=subject,
116
+ body=body,
117
+ body_html=body_html,
118
+ from_address=from_address,
119
+ attachments=list(attachments) if attachments else None,
120
+ )
100
121
  )
101
122
  if self.raise_exception is not None:
102
123
  raise self.raise_exception
@@ -129,13 +150,13 @@ class EmailSpy:
129
150
  """
130
151
  validate_recipients(recipients)
131
152
  self.sent_notifications.append(
132
- {
133
- "config": config,
134
- "recipients": recipients,
135
- "subject": subject,
136
- "message": message,
137
- "from_address": from_address,
138
- }
153
+ CapturedNotification(
154
+ config=config,
155
+ recipients=recipients,
156
+ subject=subject,
157
+ message=message,
158
+ from_address=from_address,
159
+ )
139
160
  )
140
161
  if self.raise_exception is not None:
141
162
  raise self.raise_exception
@@ -151,6 +172,8 @@ def load_email_config_from_dict_in_memory(
151
172
 
152
173
 
153
174
  __all__ = [
175
+ "CapturedEmail",
176
+ "CapturedNotification",
154
177
  "EmailSpy",
155
178
  "load_email_config_from_dict_in_memory",
156
179
  ]
@@ -566,7 +566,7 @@ def email_cli_context(
566
566
  obj=ctx.factory,
567
567
  )
568
568
  assert result.exit_code == 0
569
- assert ctx.spy.sent_notifications[0]["subject"] == "Hi"
569
+ assert ctx.spy.sent_notifications[0].subject == "Hi"
570
570
  """
571
571
  from bitranox_template_py_cli.adapters.memory import load_email_config_from_dict_in_memory
572
572
  from bitranox_template_py_cli.adapters.memory.email import EmailSpy as EmailSpyImpl
@@ -72,8 +72,8 @@ def test_when_send_email_is_invoked_with_valid_config_it_sends(
72
72
  assert result.exit_code == 0
73
73
  assert "Email sent successfully" in result.output
74
74
  assert len(ctx.spy.sent_emails) == 1
75
- assert ctx.spy.sent_emails[0]["subject"] == "Test Subject"
76
- assert ctx.spy.sent_emails[0]["recipients"] == ["recipient@test.com"]
75
+ assert ctx.spy.sent_emails[0].subject == "Test Subject"
76
+ assert ctx.spy.sent_emails[0].recipients == ["recipient@test.com"]
77
77
 
78
78
 
79
79
  @pytest.mark.os_agnostic
@@ -106,7 +106,7 @@ def test_when_send_email_receives_multiple_recipients_it_accepts_them(
106
106
  )
107
107
 
108
108
  assert result.exit_code == 0
109
- assert ctx.spy.sent_emails[0]["recipients"] == ["user1@test.com", "user2@test.com"]
109
+ assert ctx.spy.sent_emails[0].recipients == ["user1@test.com", "user2@test.com"]
110
110
 
111
111
 
112
112
  @pytest.mark.os_agnostic
@@ -139,7 +139,7 @@ def test_when_send_email_includes_html_body_it_sends(
139
139
  )
140
140
 
141
141
  assert result.exit_code == 0
142
- assert ctx.spy.sent_emails[0]["body_html"] == "<h1>HTML</h1>"
142
+ assert ctx.spy.sent_emails[0].body_html == "<h1>HTML</h1>"
143
143
 
144
144
 
145
145
  @pytest.mark.os_agnostic
@@ -178,7 +178,7 @@ def test_when_send_email_has_attachments_it_sends(
178
178
  )
179
179
 
180
180
  assert result.exit_code == 0
181
- assert ctx.spy.sent_emails[0]["attachments"] == [Path(attachment)]
181
+ assert ctx.spy.sent_emails[0].attachments == [Path(attachment)]
182
182
 
183
183
 
184
184
  @pytest.mark.os_agnostic
@@ -269,8 +269,8 @@ def test_when_send_notification_is_invoked_with_valid_config_it_sends(
269
269
  assert result.exit_code == 0
270
270
  assert "Notification sent successfully" in result.output
271
271
  assert len(ctx.spy.sent_notifications) == 1
272
- assert ctx.spy.sent_notifications[0]["subject"] == "Alert"
273
- assert ctx.spy.sent_notifications[0]["message"] == "System notification"
272
+ assert ctx.spy.sent_notifications[0].subject == "Alert"
273
+ assert ctx.spy.sent_notifications[0].message == "System notification"
274
274
 
275
275
 
276
276
  @pytest.mark.os_agnostic
@@ -303,7 +303,7 @@ def test_when_send_notification_receives_multiple_recipients_it_accepts_them(
303
303
  )
304
304
 
305
305
  assert result.exit_code == 0
306
- assert ctx.spy.sent_notifications[0]["recipients"] == ["admin1@test.com", "admin2@test.com"]
306
+ assert ctx.spy.sent_notifications[0].recipients == ["admin1@test.com", "admin2@test.com"]
307
307
 
308
308
 
309
309
  @pytest.mark.os_agnostic
@@ -371,7 +371,7 @@ def test_when_send_email_receives_smtp_host_override_it_uses_it(
371
371
  )
372
372
 
373
373
  assert result.exit_code == 0
374
- assert ctx.spy.sent_emails[0]["config"].smtp_hosts == ["smtp.override.com:465"]
374
+ assert ctx.spy.sent_emails[0].config.smtp_hosts == ["smtp.override.com:465"]
375
375
 
376
376
 
377
377
  @pytest.mark.os_agnostic
@@ -404,7 +404,7 @@ def test_when_send_email_receives_timeout_override_it_uses_it(
404
404
  )
405
405
 
406
406
  assert result.exit_code == 0
407
- assert ctx.spy.sent_emails[0]["config"].timeout == 60.0
407
+ assert ctx.spy.sent_emails[0].config.timeout == 60.0
408
408
 
409
409
 
410
410
  @pytest.mark.os_agnostic
@@ -436,7 +436,7 @@ def test_when_send_email_receives_no_use_starttls_override_it_applies_it(
436
436
  )
437
437
 
438
438
  assert result.exit_code == 0
439
- assert ctx.spy.sent_emails[0]["config"].use_starttls is False
439
+ assert ctx.spy.sent_emails[0].config.use_starttls is False
440
440
 
441
441
 
442
442
  @pytest.mark.os_agnostic
@@ -471,8 +471,8 @@ def test_when_send_email_receives_credential_overrides_it_uses_them(
471
471
  )
472
472
 
473
473
  assert result.exit_code == 0
474
- assert ctx.spy.sent_emails[0]["config"].smtp_username == "myuser"
475
- assert ctx.spy.sent_emails[0]["config"].smtp_password == "mypass"
474
+ assert ctx.spy.sent_emails[0].config.smtp_username == "myuser"
475
+ assert ctx.spy.sent_emails[0].config.smtp_password == "mypass"
476
476
 
477
477
 
478
478
  @pytest.mark.os_agnostic
@@ -505,7 +505,7 @@ def test_when_send_notification_receives_from_override_it_uses_it(
505
505
  )
506
506
 
507
507
  assert result.exit_code == 0
508
- assert ctx.spy.sent_notifications[0]["from_address"] == "override@test.com"
508
+ assert ctx.spy.sent_notifications[0].from_address == "override@test.com"
509
509
 
510
510
 
511
511
  @pytest.mark.os_agnostic
@@ -538,7 +538,7 @@ def test_when_send_notification_receives_smtp_host_override_it_uses_it(
538
538
  )
539
539
 
540
540
  assert result.exit_code == 0
541
- assert ctx.spy.sent_notifications[0]["config"].smtp_hosts == ["smtp.override.com:465"]
541
+ assert ctx.spy.sent_notifications[0].config.smtp_hosts == ["smtp.override.com:465"]
542
542
 
543
543
 
544
544
  # ======================== Attachment path validation ========================
@@ -0,0 +1,177 @@
1
+ """CLI --env-file option: path passing, validation, and value override."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import pytest
11
+ from click.testing import CliRunner, Result
12
+ from lib_layered_config import Config
13
+
14
+ from bitranox_template_py_cli.adapters import cli as cli_mod
15
+ from bitranox_template_py_cli.composition import AppServices, build_production
16
+
17
+
18
+ @dataclass
19
+ class CapturedGetConfigArgs:
20
+ """Container for captured get_config keyword arguments."""
21
+
22
+ profile: str | None
23
+ dotenv_path: str | None
24
+
25
+
26
+ @pytest.fixture
27
+ def inject_config_with_dotenv_capture(
28
+ clear_config_cache: None,
29
+ ) -> Callable[[Config, list[CapturedGetConfigArgs]], Callable[[], AppServices]]:
30
+ """Return a factory that captures dotenv_path passed to get_config."""
31
+
32
+ def _inject(config: Config, captured: list[CapturedGetConfigArgs]) -> Callable[[], AppServices]:
33
+ def _capturing_get_config(
34
+ *,
35
+ profile: str | None = None,
36
+ start_dir: str | None = None,
37
+ dotenv_path: str | None = None,
38
+ ) -> Config:
39
+ captured.append(CapturedGetConfigArgs(profile=profile, dotenv_path=dotenv_path))
40
+ return config
41
+
42
+ prod = build_production()
43
+ test_services = AppServices(
44
+ get_config=_capturing_get_config,
45
+ get_default_config_path=prod.get_default_config_path,
46
+ deploy_configuration=prod.deploy_configuration,
47
+ display_config=prod.display_config,
48
+ send_email=prod.send_email,
49
+ send_notification=prod.send_notification,
50
+ load_email_config_from_dict=prod.load_email_config_from_dict,
51
+ init_logging=prod.init_logging,
52
+ )
53
+ return lambda: test_services
54
+
55
+ return _inject
56
+
57
+
58
+ # ======================== --env-file argument passing ========================
59
+
60
+
61
+ @pytest.mark.os_agnostic
62
+ def test_env_file_passes_dotenv_path_to_get_config(
63
+ cli_runner: CliRunner,
64
+ tmp_path: Path,
65
+ config_factory: Callable[[dict[str, Any]], Config],
66
+ inject_config_with_dotenv_capture: Callable[[Config, list[CapturedGetConfigArgs]], Callable[[], AppServices]],
67
+ ) -> None:
68
+ """--env-file passes the file path as dotenv_path to get_config."""
69
+ env_file = tmp_path / ".env"
70
+ env_file.write_text("# empty\n")
71
+ config = config_factory({})
72
+ captured: list[CapturedGetConfigArgs] = []
73
+
74
+ factory = inject_config_with_dotenv_capture(config, captured)
75
+
76
+ result: Result = cli_runner.invoke(cli_mod.cli, ["--env-file", str(env_file), "hello"], obj=factory)
77
+
78
+ assert result.exit_code == 0
79
+ assert len(captured) == 1
80
+ assert captured[0].dotenv_path == str(env_file)
81
+
82
+
83
+ @pytest.mark.os_agnostic
84
+ def test_env_file_not_specified_passes_none_dotenv_path(
85
+ cli_runner: CliRunner,
86
+ config_factory: Callable[[dict[str, Any]], Config],
87
+ inject_config_with_dotenv_capture: Callable[[Config, list[CapturedGetConfigArgs]], Callable[[], AppServices]],
88
+ ) -> None:
89
+ """Without --env-file, dotenv_path is None (upward search used)."""
90
+ config = config_factory({})
91
+ captured: list[CapturedGetConfigArgs] = []
92
+
93
+ factory = inject_config_with_dotenv_capture(config, captured)
94
+
95
+ result: Result = cli_runner.invoke(cli_mod.cli, ["hello"], obj=factory)
96
+
97
+ assert result.exit_code == 0
98
+ assert len(captured) == 1
99
+ assert captured[0].dotenv_path is None
100
+
101
+
102
+ @pytest.mark.os_agnostic
103
+ def test_env_file_combined_with_profile(
104
+ cli_runner: CliRunner,
105
+ tmp_path: Path,
106
+ config_factory: Callable[[dict[str, Any]], Config],
107
+ inject_config_with_dotenv_capture: Callable[[Config, list[CapturedGetConfigArgs]], Callable[[], AppServices]],
108
+ ) -> None:
109
+ """--env-file and --profile can be used together."""
110
+ env_file = tmp_path / ".env"
111
+ env_file.write_text("# empty\n")
112
+ config = config_factory({})
113
+ captured: list[CapturedGetConfigArgs] = []
114
+
115
+ factory = inject_config_with_dotenv_capture(config, captured)
116
+
117
+ result: Result = cli_runner.invoke(
118
+ cli_mod.cli, ["--env-file", str(env_file), "--profile", "staging", "hello"], obj=factory
119
+ )
120
+
121
+ assert result.exit_code == 0
122
+ assert len(captured) == 1
123
+ assert captured[0].dotenv_path == str(env_file)
124
+ assert captured[0].profile == "staging"
125
+
126
+
127
+ # ======================== --env-file validation ========================
128
+
129
+
130
+ @pytest.mark.os_agnostic
131
+ def test_env_file_nonexistent_path_rejected(
132
+ cli_runner: CliRunner,
133
+ production_factory: Callable[[], AppServices],
134
+ ) -> None:
135
+ """--env-file with nonexistent path is rejected by Click."""
136
+ result: Result = cli_runner.invoke(
137
+ cli_mod.cli, ["--env-file", "/tmp/nonexistent_env_file_xyz.env", "hello"], obj=production_factory
138
+ )
139
+
140
+ assert result.exit_code != 0
141
+ assert "does not exist" in result.output
142
+
143
+
144
+ @pytest.mark.os_agnostic
145
+ def test_env_file_directory_rejected(
146
+ cli_runner: CliRunner,
147
+ tmp_path: Path,
148
+ production_factory: Callable[[], AppServices],
149
+ ) -> None:
150
+ """--env-file with a directory path is rejected by Click."""
151
+ result: Result = cli_runner.invoke(cli_mod.cli, ["--env-file", str(tmp_path), "hello"], obj=production_factory)
152
+
153
+ assert result.exit_code != 0
154
+
155
+
156
+ # ======================== --env-file end-to-end integration ========================
157
+
158
+
159
+ @pytest.mark.os_agnostic
160
+ def test_env_file_values_appear_in_config(
161
+ cli_runner: CliRunner,
162
+ tmp_path: Path,
163
+ clear_config_cache: None,
164
+ production_factory: Callable[[], AppServices],
165
+ ) -> None:
166
+ """Values from an --env-file are visible in the merged config output."""
167
+ env_file = tmp_path / ".env"
168
+ env_file.write_text("LIB_LOG_RICH__ENVIRONMENT=from-env-file\n")
169
+
170
+ result: Result = cli_runner.invoke(
171
+ cli_mod.cli,
172
+ ["--env-file", str(env_file), "config", "--format", "json", "--section", "lib_log_rich"],
173
+ obj=production_factory,
174
+ )
175
+
176
+ assert result.exit_code == 0
177
+ assert "from-env-file" in result.stdout
@@ -36,13 +36,13 @@ def test_get_permission_defaults_returns_library_defaults_when_not_configured(
36
36
 
37
37
  defaults = get_permission_defaults(config)
38
38
 
39
- assert defaults["app_directory"] == 0o755
40
- assert defaults["app_file"] == 0o644
41
- assert defaults["host_directory"] == 0o755
42
- assert defaults["host_file"] == 0o644
43
- assert defaults["user_directory"] == 0o700
44
- assert defaults["user_file"] == 0o600
45
- assert defaults["enabled"] is True
39
+ assert defaults.app_directory == 0o755
40
+ assert defaults.app_file == 0o644
41
+ assert defaults.host_directory == 0o755
42
+ assert defaults.host_file == 0o644
43
+ assert defaults.user_directory == 0o700
44
+ assert defaults.user_file == 0o600
45
+ assert defaults.enabled is True
46
46
 
47
47
 
48
48
  @pytest.mark.os_agnostic
@@ -64,11 +64,11 @@ def test_get_permission_defaults_reads_from_config(
64
64
 
65
65
  defaults = get_permission_defaults(config)
66
66
 
67
- assert defaults["user_directory"] == 0o750
68
- assert defaults["user_file"] == 0o640
69
- assert defaults["enabled"] is False
67
+ assert defaults.user_directory == 0o750
68
+ assert defaults.user_file == 0o640
69
+ assert defaults.enabled is False
70
70
  # Non-overridden values use library defaults
71
- assert defaults["app_directory"] == 0o755
71
+ assert defaults.app_directory == 0o755
72
72
 
73
73
 
74
74
  @pytest.mark.os_agnostic
@@ -185,8 +185,8 @@ def test_get_permission_defaults_accepts_octal_strings(
185
185
 
186
186
  defaults = get_permission_defaults(config)
187
187
 
188
- assert defaults["user_directory"] == 0o750
189
- assert defaults["user_file"] == 0o640
188
+ assert defaults.user_directory == 0o750
189
+ assert defaults.user_file == 0o640
190
190
 
191
191
 
192
192
  # ======================== CLI Option Parsing Tests ========================