dotenvmodel 0.3.1__tar.gz → 0.3.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.
- dotenvmodel-0.3.2/.release-please-manifest.json +3 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/CHANGELOG.md +7 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/PKG-INFO +44 -1
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/README.md +43 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/__init__.py +1 -1
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/config.py +39 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/pyproject.toml +4 -4
- dotenvmodel-0.3.2/tests/test_nested_config.py +238 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/uv.lock +174 -186
- dotenvmodel-0.3.1/.release-please-manifest.json +0 -3
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/CODEOWNERS +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/dependabot.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/pull_request_template.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/workflows/ci.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/workflows/docs.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/workflows/publish.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/workflows/release-please.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.gitignore +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.python-version +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/CODE_OF_CONDUCT.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/CONTRIBUTING.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/LICENSE +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/Makefile +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/SECURITY.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/coercion.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/config.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/constants.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/describe-formatters.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/describe-renderers.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/describe.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/dotenvmodel.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/exceptions.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/fields.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/loading.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/logging-config.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/metaclass.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/types.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/validation.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/changelog.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/examples/complete-app.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/getting-started/installation.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/getting-started/quick-start.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/configuration-docs.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/error-handling.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/fields.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/loading.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/logging.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/prefixes.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/types.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/validation.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/index.md +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/_constants.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/_redaction.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/coercion.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/describe/__init__.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/describe/formatters.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/describe/renderers.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/exceptions.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/fields.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/loading.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/logging_config.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/metaclass.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/py.typed +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/types.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/validation.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/examples/advanced_types.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/examples/basic_usage.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/examples/describe_documentation.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/examples/generate_env_example.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/examples/logging_example.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/mkdocs.yml +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/release-please-config.json +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/__init__.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/conftest.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_basic.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_coercion.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_collection_validators.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_describe.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_adoption.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_datetime.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_describe_output.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_enum.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_field_options.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_numbers.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_path_options.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_strings.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_empty_collection_items.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_empty_strings.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_enum.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_errors.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_field_constraints.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_future_annotations.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_inheritance.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_json.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_json_and_optional.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_loading.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_logging_config.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_prefix.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_reload.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_secret_redaction.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_secretstr_security.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_types.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_union_types.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_url_dsn.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_url_password_decoding.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_url_unquote.py +0 -0
- {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_urls.py +0 -0
|
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.3.2](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.1...v0.3.2) (2026-07-15)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* resolve nested DotEnvConfig field defaults not being loaded ([#34](https://github.com/AZX-PBC-OSS/dotenvmodel/issues/34)) ([4a3bb88](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/4a3bb8864dad044977d7b72be41e15307fff8f4d))
|
|
14
|
+
|
|
8
15
|
## [0.3.1](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.0...v0.3.1) (2026-07-13)
|
|
9
16
|
|
|
10
17
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dotenvmodel
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: Type-safe environment configuration with automatic .env file loading
|
|
5
5
|
Project-URL: Homepage, https://github.com/AZX-PBC-OSS/dotenvmodel
|
|
6
6
|
Project-URL: Repository, https://github.com/AZX-PBC-OSS/dotenvmodel
|
|
@@ -1036,6 +1036,30 @@ redis = RedisConfig.load() # Reads REDIS_HOST, REDIS_PORT
|
|
|
1036
1036
|
app = AppConfig.load() # Reads APP_NAME, APP_VERSION
|
|
1037
1037
|
```
|
|
1038
1038
|
|
|
1039
|
+
### Nested Configuration
|
|
1040
|
+
|
|
1041
|
+
A field typed as another `DotEnvConfig` subclass is loaded as a nested config, resolving its own fields (including defaults) from its own `env_prefix` — independently of the parent's prefix:
|
|
1042
|
+
|
|
1043
|
+
```python
|
|
1044
|
+
class OIDCSettings(DotEnvConfig):
|
|
1045
|
+
env_prefix = "WARDEN_OIDC_"
|
|
1046
|
+
issuer: str = Field(default="")
|
|
1047
|
+
session_max_age_seconds: int = Field(default=28800)
|
|
1048
|
+
|
|
1049
|
+
class Settings(DotEnvConfig):
|
|
1050
|
+
env_prefix = "WARDEN_"
|
|
1051
|
+
service_name: str = Field(default="warden")
|
|
1052
|
+
oidc: OIDCSettings = Field(default_factory=OIDCSettings)
|
|
1053
|
+
|
|
1054
|
+
settings = Settings.load_from_dict({
|
|
1055
|
+
"WARDEN_OIDC_SESSION_MAX_AGE_SECONDS": "3600",
|
|
1056
|
+
})
|
|
1057
|
+
settings.oidc.session_max_age_seconds # 3600 (overridden)
|
|
1058
|
+
settings.oidc.issuer # "" (nested default, not the parent's prefix)
|
|
1059
|
+
```
|
|
1060
|
+
|
|
1061
|
+
Nested `env_prefix` values are **absolute, not concatenated** with the parent's — `OIDCSettings.env_prefix` above is `WARDEN_OIDC_` in full, not appended to `Settings.env_prefix`. `.load()`, `.load_from_dict()`, and `.reload()` all resolve nested fields the same way. See [Known Limitations](#known-limitations) for `Optional[NestedConfig]` and `describe()` caveats.
|
|
1062
|
+
|
|
1039
1063
|
## Error Handling
|
|
1040
1064
|
|
|
1041
1065
|
### Missing Required Field
|
|
@@ -1264,6 +1288,25 @@ config = Config.load()
|
|
|
1264
1288
|
value_as_int = int(config.value) if config.value.isdigit() else config.value
|
|
1265
1289
|
```
|
|
1266
1290
|
|
|
1291
|
+
### Optional Nested Configuration
|
|
1292
|
+
|
|
1293
|
+
`Optional[NestedConfig]` / `NestedConfig | None` fields are **not** supported the way a plain `nested: NestedConfig = Field(default_factory=NestedConfig)` field is — an Optional-typed nested field falls through to ordinary Optional-scalar handling and silently resolves to `None`, discarding any env vars set at the nested prefix:
|
|
1294
|
+
|
|
1295
|
+
```python
|
|
1296
|
+
class Config(DotEnvConfig):
|
|
1297
|
+
env_prefix = "APP_"
|
|
1298
|
+
nested: NestedConfig | None = Field(default=None)
|
|
1299
|
+
|
|
1300
|
+
config = Config.load_from_dict({"APP_NESTED_PORT": "8080"})
|
|
1301
|
+
config.nested # None — the override above is silently ignored
|
|
1302
|
+
```
|
|
1303
|
+
|
|
1304
|
+
**Workaround**: give the nested field a non-Optional type with `default_factory`, as in [Nested Configuration](#nested-configuration) above. If the nested config is conditionally absent in your app, model that with a `bool`/enum field inside the nested config itself rather than making the field Optional.
|
|
1305
|
+
|
|
1306
|
+
### `describe()` / `generate_env_example()` and Nested Configuration
|
|
1307
|
+
|
|
1308
|
+
`describe()` and `generate_env_example()` do not recurse into nested config fields — a nested field renders as an opaque, non-functional placeholder (e.g. `APP_NESTED=<NestedConfig()>`) rather than expanding to the nested class's real, settable env vars (e.g. `APP_NESTED_PORT`). Don't rely on generated `.env.example` output to discover a nested config's real variables — read the nested class's own fields (or its own `describe()` output) directly.
|
|
1309
|
+
|
|
1267
1310
|
## License
|
|
1268
1311
|
|
|
1269
1312
|
MIT License - see [LICENSE](LICENSE) for details.
|
|
@@ -1008,6 +1008,30 @@ redis = RedisConfig.load() # Reads REDIS_HOST, REDIS_PORT
|
|
|
1008
1008
|
app = AppConfig.load() # Reads APP_NAME, APP_VERSION
|
|
1009
1009
|
```
|
|
1010
1010
|
|
|
1011
|
+
### Nested Configuration
|
|
1012
|
+
|
|
1013
|
+
A field typed as another `DotEnvConfig` subclass is loaded as a nested config, resolving its own fields (including defaults) from its own `env_prefix` — independently of the parent's prefix:
|
|
1014
|
+
|
|
1015
|
+
```python
|
|
1016
|
+
class OIDCSettings(DotEnvConfig):
|
|
1017
|
+
env_prefix = "WARDEN_OIDC_"
|
|
1018
|
+
issuer: str = Field(default="")
|
|
1019
|
+
session_max_age_seconds: int = Field(default=28800)
|
|
1020
|
+
|
|
1021
|
+
class Settings(DotEnvConfig):
|
|
1022
|
+
env_prefix = "WARDEN_"
|
|
1023
|
+
service_name: str = Field(default="warden")
|
|
1024
|
+
oidc: OIDCSettings = Field(default_factory=OIDCSettings)
|
|
1025
|
+
|
|
1026
|
+
settings = Settings.load_from_dict({
|
|
1027
|
+
"WARDEN_OIDC_SESSION_MAX_AGE_SECONDS": "3600",
|
|
1028
|
+
})
|
|
1029
|
+
settings.oidc.session_max_age_seconds # 3600 (overridden)
|
|
1030
|
+
settings.oidc.issuer # "" (nested default, not the parent's prefix)
|
|
1031
|
+
```
|
|
1032
|
+
|
|
1033
|
+
Nested `env_prefix` values are **absolute, not concatenated** with the parent's — `OIDCSettings.env_prefix` above is `WARDEN_OIDC_` in full, not appended to `Settings.env_prefix`. `.load()`, `.load_from_dict()`, and `.reload()` all resolve nested fields the same way. See [Known Limitations](#known-limitations) for `Optional[NestedConfig]` and `describe()` caveats.
|
|
1034
|
+
|
|
1011
1035
|
## Error Handling
|
|
1012
1036
|
|
|
1013
1037
|
### Missing Required Field
|
|
@@ -1236,6 +1260,25 @@ config = Config.load()
|
|
|
1236
1260
|
value_as_int = int(config.value) if config.value.isdigit() else config.value
|
|
1237
1261
|
```
|
|
1238
1262
|
|
|
1263
|
+
### Optional Nested Configuration
|
|
1264
|
+
|
|
1265
|
+
`Optional[NestedConfig]` / `NestedConfig | None` fields are **not** supported the way a plain `nested: NestedConfig = Field(default_factory=NestedConfig)` field is — an Optional-typed nested field falls through to ordinary Optional-scalar handling and silently resolves to `None`, discarding any env vars set at the nested prefix:
|
|
1266
|
+
|
|
1267
|
+
```python
|
|
1268
|
+
class Config(DotEnvConfig):
|
|
1269
|
+
env_prefix = "APP_"
|
|
1270
|
+
nested: NestedConfig | None = Field(default=None)
|
|
1271
|
+
|
|
1272
|
+
config = Config.load_from_dict({"APP_NESTED_PORT": "8080"})
|
|
1273
|
+
config.nested # None — the override above is silently ignored
|
|
1274
|
+
```
|
|
1275
|
+
|
|
1276
|
+
**Workaround**: give the nested field a non-Optional type with `default_factory`, as in [Nested Configuration](#nested-configuration) above. If the nested config is conditionally absent in your app, model that with a `bool`/enum field inside the nested config itself rather than making the field Optional.
|
|
1277
|
+
|
|
1278
|
+
### `describe()` / `generate_env_example()` and Nested Configuration
|
|
1279
|
+
|
|
1280
|
+
`describe()` and `generate_env_example()` do not recurse into nested config fields — a nested field renders as an opaque, non-functional placeholder (e.g. `APP_NESTED=<NestedConfig()>`) rather than expanding to the nested class's real, settable env vars (e.g. `APP_NESTED_PORT`). Don't rely on generated `.env.example` output to discover a nested config's real variables — read the nested class's own fields (or its own `describe()` output) directly.
|
|
1281
|
+
|
|
1239
1282
|
## License
|
|
1240
1283
|
|
|
1241
1284
|
MIT License - see [LICENSE](LICENSE) for details.
|
|
@@ -78,6 +78,7 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
78
78
|
raw_value: str | None,
|
|
79
79
|
env_var_name: str,
|
|
80
80
|
*,
|
|
81
|
+
env_source: builtins.dict[str, str] | None = None,
|
|
81
82
|
validate: bool = True,
|
|
82
83
|
) -> Any:
|
|
83
84
|
"""
|
|
@@ -89,6 +90,10 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
89
90
|
field_info: Field metadata
|
|
90
91
|
raw_value: Raw string value from environment (or None)
|
|
91
92
|
env_var_name: Environment variable name for error messages
|
|
93
|
+
env_source: The source passed to the enclosing `_load_fields` call
|
|
94
|
+
(None for real env vars, or a dict for `load_from_dict`).
|
|
95
|
+
Forwarded to nested `DotEnvConfig` fields so they resolve
|
|
96
|
+
from the same source as their parent.
|
|
92
97
|
validate: Whether to perform validation (default: True)
|
|
93
98
|
|
|
94
99
|
Returns:
|
|
@@ -98,6 +103,33 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
98
103
|
MissingFieldError: If required field is missing
|
|
99
104
|
ValidationError: If validation fails
|
|
100
105
|
"""
|
|
106
|
+
# Nested DotEnvConfig fields (e.g. `oidc: OIDCSettings`) are not a
|
|
107
|
+
# scalar to coerce — a nested config resolves its own fields from
|
|
108
|
+
# the same env_source using its own env_prefix, so it must always
|
|
109
|
+
# go through _load_fields() regardless of whether raw_value (the
|
|
110
|
+
# value of an env var literally named after the field, which no
|
|
111
|
+
# one sets) is present. A bare `field_type()` here would silently
|
|
112
|
+
# produce an unloaded instance whose fields never got populated.
|
|
113
|
+
#
|
|
114
|
+
# Note: this only matches a plain `type` — `Optional[Nested]` /
|
|
115
|
+
# `Nested | None` is a Union, not a `type`, so it does NOT take
|
|
116
|
+
# this branch and instead falls through to the Optional handling
|
|
117
|
+
# in coerce_value(), which just returns None for a missing literal
|
|
118
|
+
# env var without trying the nested prefix. See
|
|
119
|
+
# TestOptionalNestedConfigLimitation below — tracked as a known
|
|
120
|
+
# follow-up, not fixed here.
|
|
121
|
+
#
|
|
122
|
+
# Note: field_info.required is deliberately not consulted here —
|
|
123
|
+
# a `Field()`-required (no default) nested config now always
|
|
124
|
+
# resolves successfully using the nested class's own defaults,
|
|
125
|
+
# rather than raising MissingFieldError. "Required" is expressed
|
|
126
|
+
# on the nested class's own fields instead. See
|
|
127
|
+
# TestRequiredNestedConfigField below for the pinned behavior.
|
|
128
|
+
if isinstance(field_type, type) and issubclass(field_type, DotEnvConfig):
|
|
129
|
+
nested = field_type()
|
|
130
|
+
nested._load_fields(env_source, validate=validate)
|
|
131
|
+
return nested
|
|
132
|
+
|
|
101
133
|
# Handle missing values
|
|
102
134
|
if raw_value is None:
|
|
103
135
|
if field_info.required:
|
|
@@ -164,11 +196,18 @@ class DotEnvConfig(metaclass=ConfigMeta):
|
|
|
164
196
|
field_info,
|
|
165
197
|
raw_value,
|
|
166
198
|
env_var_name,
|
|
199
|
+
env_source=env_source,
|
|
167
200
|
validate=validate,
|
|
168
201
|
)
|
|
169
202
|
setattr(self, field_name, value)
|
|
170
203
|
except ValidationError as e:
|
|
171
204
|
errors.append(e)
|
|
205
|
+
except MultipleValidationErrors as e:
|
|
206
|
+
# Raised when a nested DotEnvConfig field (see
|
|
207
|
+
# _process_field) has multiple invalid fields of its own —
|
|
208
|
+
# flatten into this level's collection instead of letting
|
|
209
|
+
# it escape uncaught past the aggregation loop.
|
|
210
|
+
errors.extend(e.errors)
|
|
172
211
|
|
|
173
212
|
if errors:
|
|
174
213
|
if len(errors) == 1:
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dotenvmodel"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.2"
|
|
8
8
|
description = "Type-safe environment configuration with automatic .env file loading"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -49,13 +49,13 @@ packages = ["dotenvmodel"]
|
|
|
49
49
|
dev = [
|
|
50
50
|
"mkdocs>=1.6.1",
|
|
51
51
|
"mkdocs-material>=9.7.6",
|
|
52
|
-
"mkdocstrings[python]>=1.0.
|
|
52
|
+
"mkdocstrings[python]>=1.0.6",
|
|
53
53
|
"pip-audit>=2.10.1",
|
|
54
54
|
"pyright>=1.1.411",
|
|
55
55
|
"pytest>=9.1.1",
|
|
56
56
|
"pytest-cov>=7.1.0",
|
|
57
|
-
"ruff>=0.15.
|
|
58
|
-
"ty>=0.0.
|
|
57
|
+
"ruff>=0.15.21",
|
|
58
|
+
"ty>=0.0.59",
|
|
59
59
|
]
|
|
60
60
|
|
|
61
61
|
[tool.ruff]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Tests for nested DotEnvConfig fields (a config field whose type is itself
|
|
2
|
+
a DotEnvConfig subclass, e.g. `oidc: OIDCSettings = Field(default_factory=OIDCSettings)`).
|
|
3
|
+
|
|
4
|
+
Regression coverage for a bug where a nested config's own declared field
|
|
5
|
+
defaults silently resolved to None: the metaclass resets every field's
|
|
6
|
+
class attribute to None at class-definition time (to avoid sharing mutable
|
|
7
|
+
FieldInfo objects across instances) and relies on _load_fields() being
|
|
8
|
+
called at instantiation to repopulate them. A nested field's default_factory
|
|
9
|
+
bare-constructs the nested class without ever calling _load_fields() on it,
|
|
10
|
+
so every one of its fields stayed at that reset-to-None class attribute.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from dotenvmodel import DotEnvConfig, Field
|
|
16
|
+
from dotenvmodel.exceptions import ConstraintViolationError, MultipleValidationErrors
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Nested(DotEnvConfig):
|
|
20
|
+
env_prefix = "APP_NESTED_"
|
|
21
|
+
port: int = Field(default=10, ge=1, le=100)
|
|
22
|
+
name: str = Field(default="nested-default")
|
|
23
|
+
enabled: bool = Field(default=True)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Outer(DotEnvConfig):
|
|
27
|
+
env_prefix = "APP_"
|
|
28
|
+
top: str = Field(default="top-default")
|
|
29
|
+
nested: Nested = Field(default_factory=Nested)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestNestedConfigDefaults:
|
|
33
|
+
def test_load_from_dict_resolves_nested_defaults(self) -> None:
|
|
34
|
+
config = Outer.load_from_dict({})
|
|
35
|
+
|
|
36
|
+
assert config.top == "top-default"
|
|
37
|
+
assert config.nested.port == 10
|
|
38
|
+
assert config.nested.name == "nested-default"
|
|
39
|
+
assert config.nested.enabled is True
|
|
40
|
+
|
|
41
|
+
def test_load_resolves_nested_defaults_with_empty_environment(
|
|
42
|
+
self, monkeypatch: pytest.MonkeyPatch
|
|
43
|
+
) -> None:
|
|
44
|
+
monkeypatch.delenv("APP_TOP", raising=False)
|
|
45
|
+
monkeypatch.delenv("APP_NESTED_PORT", raising=False)
|
|
46
|
+
monkeypatch.delenv("APP_NESTED_NAME", raising=False)
|
|
47
|
+
monkeypatch.delenv("APP_NESTED_ENABLED", raising=False)
|
|
48
|
+
|
|
49
|
+
config = Outer.load()
|
|
50
|
+
|
|
51
|
+
assert config.nested.port == 10
|
|
52
|
+
assert config.nested.name == "nested-default"
|
|
53
|
+
assert config.nested.enabled is True
|
|
54
|
+
|
|
55
|
+
def test_nested_default_is_a_real_instance_not_none(self) -> None:
|
|
56
|
+
config = Outer.load_from_dict({})
|
|
57
|
+
|
|
58
|
+
assert isinstance(config.nested, Nested)
|
|
59
|
+
assert config.nested.port is not None
|
|
60
|
+
assert config.nested.name is not None
|
|
61
|
+
assert config.nested.enabled is not None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class TestNestedConfigOverrides:
|
|
65
|
+
def test_env_var_with_nested_prefix_overrides_default(self) -> None:
|
|
66
|
+
config = Outer.load_from_dict({"APP_NESTED_PORT": "55"})
|
|
67
|
+
|
|
68
|
+
assert config.nested.port == 55
|
|
69
|
+
# Sibling nested fields keep their own defaults
|
|
70
|
+
assert config.nested.name == "nested-default"
|
|
71
|
+
|
|
72
|
+
def test_nested_prefix_is_independent_of_parent_prefix(self) -> None:
|
|
73
|
+
config = Outer.load_from_dict({"APP_TOP": "override", "APP_NESTED_NAME": "override-nested"})
|
|
74
|
+
|
|
75
|
+
assert config.top == "override"
|
|
76
|
+
assert config.nested.name == "override-nested"
|
|
77
|
+
|
|
78
|
+
def test_env_var_named_after_outer_field_does_not_override_nested(self) -> None:
|
|
79
|
+
# Setting "APP_NESTED" (the outer field's own env var name) should
|
|
80
|
+
# not be interpreted as a value for the nested config as a whole —
|
|
81
|
+
# nested configs are never scalar-coerced.
|
|
82
|
+
config = Outer.load_from_dict({"APP_NESTED": "not-a-real-value"})
|
|
83
|
+
|
|
84
|
+
assert isinstance(config.nested, Nested)
|
|
85
|
+
assert config.nested.port == 10
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestNestedConfigValidation:
|
|
89
|
+
def test_invalid_nested_field_raises_constraint_violation(self) -> None:
|
|
90
|
+
with pytest.raises(ConstraintViolationError):
|
|
91
|
+
Outer.load_from_dict({"APP_NESTED_PORT": "999"})
|
|
92
|
+
|
|
93
|
+
def test_invalid_nested_and_outer_field_aggregate_into_multiple_errors(self) -> None:
|
|
94
|
+
class OuterWithConstraint(DotEnvConfig):
|
|
95
|
+
env_prefix = "APP_"
|
|
96
|
+
top: int = Field(default=1, ge=1, le=10)
|
|
97
|
+
nested: Nested = Field(default_factory=Nested)
|
|
98
|
+
|
|
99
|
+
with pytest.raises(MultipleValidationErrors) as exc_info:
|
|
100
|
+
OuterWithConstraint.load_from_dict({"APP_TOP": "999", "APP_NESTED_PORT": "999"})
|
|
101
|
+
|
|
102
|
+
assert len(exc_info.value.errors) == 2
|
|
103
|
+
field_names = {e.field_name for e in exc_info.value.errors}
|
|
104
|
+
assert field_names == {"top", "port"}
|
|
105
|
+
|
|
106
|
+
def test_validate_false_skips_nested_validation(self) -> None:
|
|
107
|
+
config = Outer.load_from_dict({"APP_NESTED_PORT": "999"}, validate=False)
|
|
108
|
+
|
|
109
|
+
assert config.nested.port == 999
|
|
110
|
+
|
|
111
|
+
def test_multiple_invalid_fields_within_single_nested_config_aggregate(self) -> None:
|
|
112
|
+
# Exercises the `except MultipleValidationErrors` branch in
|
|
113
|
+
# _load_fields specifically: a single nested config raising its
|
|
114
|
+
# own MultipleValidationErrors (>=2 bad fields inside ONE nested
|
|
115
|
+
# instance) must be caught and flattened into the parent's error
|
|
116
|
+
# list, not just two independent single-field ValidationErrors
|
|
117
|
+
# bubbling from two different fields (that's a different code path
|
|
118
|
+
# — see test_invalid_nested_and_outer_field_aggregate_into_multiple_errors).
|
|
119
|
+
class TwoConstrainedFields(DotEnvConfig):
|
|
120
|
+
env_prefix = "APP_TWO_"
|
|
121
|
+
a: int = Field(default=1, ge=1, le=10)
|
|
122
|
+
b: int = Field(default=1, ge=1, le=10)
|
|
123
|
+
|
|
124
|
+
class OuterWithTwoConstrainedNested(DotEnvConfig):
|
|
125
|
+
env_prefix = "APP_"
|
|
126
|
+
nested: TwoConstrainedFields = Field(default_factory=TwoConstrainedFields)
|
|
127
|
+
|
|
128
|
+
with pytest.raises(MultipleValidationErrors) as exc_info:
|
|
129
|
+
OuterWithTwoConstrainedNested.load_from_dict({"APP_TWO_A": "999", "APP_TWO_B": "999"})
|
|
130
|
+
|
|
131
|
+
assert len(exc_info.value.errors) == 2
|
|
132
|
+
field_names = {e.field_name for e in exc_info.value.errors}
|
|
133
|
+
assert field_names == {"a", "b"}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestOptionalNestedConfigLimitation:
|
|
137
|
+
"""Pins a KNOWN, UNFIXED gap: `Optional[Nested]` / `Nested | None` does
|
|
138
|
+
not take the nested-loading branch (it's a Union, not a plain `type`),
|
|
139
|
+
so it falls through to Optional scalar handling and silently discards
|
|
140
|
+
the nested config instead of resolving it. This behavior is identical
|
|
141
|
+
before and after the nested-config-defaults fix — not a regression,
|
|
142
|
+
but a documented limitation so it isn't rediscovered as a surprise.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def test_optional_nested_config_silently_resolves_to_none(self) -> None:
|
|
146
|
+
class OuterOptional(DotEnvConfig):
|
|
147
|
+
env_prefix = "APP_"
|
|
148
|
+
nested: Nested | None = Field(default=None)
|
|
149
|
+
|
|
150
|
+
config = OuterOptional.load_from_dict({"APP_NESTED_PORT": "55"})
|
|
151
|
+
|
|
152
|
+
# Known limitation: the override is silently discarded rather than
|
|
153
|
+
# applied — `nested` stays None even though APP_NESTED_PORT was set.
|
|
154
|
+
assert config.nested is None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class TestRequiredNestedConfigField:
|
|
158
|
+
"""Pins the (new, intentional) behavior for a nested config field with
|
|
159
|
+
no default_factory: field_info.required is not consulted for nested
|
|
160
|
+
DotEnvConfig fields, so it always resolves via the nested class's own
|
|
161
|
+
defaults rather than raising MissingFieldError. "Required" is expected
|
|
162
|
+
to be expressed on the nested class's own fields instead.
|
|
163
|
+
"""
|
|
164
|
+
|
|
165
|
+
def test_required_nested_field_resolves_via_nested_defaults(self) -> None:
|
|
166
|
+
class OuterRequiredNested(DotEnvConfig):
|
|
167
|
+
env_prefix = "APP_"
|
|
168
|
+
nested: Nested = Field() # no default_factory — required=True
|
|
169
|
+
|
|
170
|
+
config = OuterRequiredNested.load_from_dict({})
|
|
171
|
+
|
|
172
|
+
assert isinstance(config.nested, Nested)
|
|
173
|
+
assert config.nested.port == 10
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class TestNestedConfigReload:
|
|
177
|
+
def test_reload_picks_up_nested_env_changes(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
178
|
+
monkeypatch.setenv("APP_NESTED_PORT", "20")
|
|
179
|
+
config = Outer.load()
|
|
180
|
+
assert config.nested.port == 20
|
|
181
|
+
|
|
182
|
+
monkeypatch.setenv("APP_NESTED_PORT", "30")
|
|
183
|
+
config.reload()
|
|
184
|
+
assert config.nested.port == 30
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class TestNestedConfigIntrospection:
|
|
188
|
+
def test_dict_includes_nested_instance(self) -> None:
|
|
189
|
+
config = Outer.load_from_dict({})
|
|
190
|
+
|
|
191
|
+
as_dict = config.dict()
|
|
192
|
+
assert isinstance(as_dict["nested"], Nested)
|
|
193
|
+
assert as_dict["nested"].port == 10
|
|
194
|
+
|
|
195
|
+
def test_repr_renders_nested_instance(self) -> None:
|
|
196
|
+
config = Outer.load_from_dict({})
|
|
197
|
+
|
|
198
|
+
rendered = repr(config)
|
|
199
|
+
assert "Nested(" in rendered
|
|
200
|
+
assert "port=10" in rendered
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class TestDeeplyNestedConfig:
|
|
204
|
+
def test_two_levels_of_nesting_resolve_defaults(self) -> None:
|
|
205
|
+
class Inner(DotEnvConfig):
|
|
206
|
+
env_prefix = "APP_MID_INNER_"
|
|
207
|
+
value: int = Field(default=7)
|
|
208
|
+
|
|
209
|
+
class Mid(DotEnvConfig):
|
|
210
|
+
env_prefix = "APP_MID_"
|
|
211
|
+
inner: Inner = Field(default_factory=Inner)
|
|
212
|
+
label: str = Field(default="mid-default")
|
|
213
|
+
|
|
214
|
+
class Top(DotEnvConfig):
|
|
215
|
+
env_prefix = "APP_"
|
|
216
|
+
mid: Mid = Field(default_factory=Mid)
|
|
217
|
+
|
|
218
|
+
config = Top.load_from_dict({})
|
|
219
|
+
|
|
220
|
+
assert config.mid.label == "mid-default"
|
|
221
|
+
assert config.mid.inner.value == 7
|
|
222
|
+
|
|
223
|
+
def test_two_levels_of_nesting_apply_overrides(self) -> None:
|
|
224
|
+
class Inner(DotEnvConfig):
|
|
225
|
+
env_prefix = "APP_MID_INNER_"
|
|
226
|
+
value: int = Field(default=7)
|
|
227
|
+
|
|
228
|
+
class Mid(DotEnvConfig):
|
|
229
|
+
env_prefix = "APP_MID_"
|
|
230
|
+
inner: Inner = Field(default_factory=Inner)
|
|
231
|
+
|
|
232
|
+
class Top(DotEnvConfig):
|
|
233
|
+
env_prefix = "APP_"
|
|
234
|
+
mid: Mid = Field(default_factory=Mid)
|
|
235
|
+
|
|
236
|
+
config = Top.load_from_dict({"APP_MID_INNER_VALUE": "42"})
|
|
237
|
+
|
|
238
|
+
assert config.mid.inner.value == 42
|