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.
Files changed (109) hide show
  1. dotenvmodel-0.3.2/.release-please-manifest.json +3 -0
  2. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/CHANGELOG.md +7 -0
  3. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/PKG-INFO +44 -1
  4. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/README.md +43 -0
  5. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/__init__.py +1 -1
  6. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/config.py +39 -0
  7. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/pyproject.toml +4 -4
  8. dotenvmodel-0.3.2/tests/test_nested_config.py +238 -0
  9. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/uv.lock +174 -186
  10. dotenvmodel-0.3.1/.release-please-manifest.json +0 -3
  11. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/CODEOWNERS +0 -0
  12. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  13. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  14. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/dependabot.yml +0 -0
  15. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/pull_request_template.md +0 -0
  16. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/workflows/ci.yml +0 -0
  17. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/workflows/docs.yml +0 -0
  18. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/workflows/publish.yml +0 -0
  19. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.github/workflows/release-please.yml +0 -0
  20. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.gitignore +0 -0
  21. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/.python-version +0 -0
  22. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/CODE_OF_CONDUCT.md +0 -0
  23. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/CONTRIBUTING.md +0 -0
  24. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/LICENSE +0 -0
  25. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/Makefile +0 -0
  26. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/SECURITY.md +0 -0
  27. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/coercion.md +0 -0
  28. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/config.md +0 -0
  29. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/constants.md +0 -0
  30. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/describe-formatters.md +0 -0
  31. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/describe-renderers.md +0 -0
  32. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/describe.md +0 -0
  33. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/dotenvmodel.md +0 -0
  34. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/exceptions.md +0 -0
  35. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/fields.md +0 -0
  36. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/loading.md +0 -0
  37. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/logging-config.md +0 -0
  38. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/metaclass.md +0 -0
  39. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/types.md +0 -0
  40. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/api-reference/validation.md +0 -0
  41. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/changelog.md +0 -0
  42. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/examples/complete-app.md +0 -0
  43. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/getting-started/installation.md +0 -0
  44. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/getting-started/quick-start.md +0 -0
  45. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/configuration-docs.md +0 -0
  46. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/error-handling.md +0 -0
  47. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/fields.md +0 -0
  48. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/loading.md +0 -0
  49. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/logging.md +0 -0
  50. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/prefixes.md +0 -0
  51. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/types.md +0 -0
  52. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/guides/validation.md +0 -0
  53. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/docs/index.md +0 -0
  54. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/_constants.py +0 -0
  55. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/_redaction.py +0 -0
  56. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/coercion.py +0 -0
  57. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/describe/__init__.py +0 -0
  58. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/describe/formatters.py +0 -0
  59. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/describe/renderers.py +0 -0
  60. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/exceptions.py +0 -0
  61. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/fields.py +0 -0
  62. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/loading.py +0 -0
  63. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/logging_config.py +0 -0
  64. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/metaclass.py +0 -0
  65. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/py.typed +0 -0
  66. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/types.py +0 -0
  67. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/dotenvmodel/validation.py +0 -0
  68. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/examples/advanced_types.py +0 -0
  69. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/examples/basic_usage.py +0 -0
  70. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/examples/describe_documentation.py +0 -0
  71. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/examples/generate_env_example.py +0 -0
  72. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/examples/logging_example.py +0 -0
  73. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/mkdocs.yml +0 -0
  74. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/release-please-config.json +0 -0
  75. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/__init__.py +0 -0
  76. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/conftest.py +0 -0
  77. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_basic.py +0 -0
  78. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_coercion.py +0 -0
  79. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_collection_validators.py +0 -0
  80. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_describe.py +0 -0
  81. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_adoption.py +0 -0
  82. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_datetime.py +0 -0
  83. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_describe_output.py +0 -0
  84. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_enum.py +0 -0
  85. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_field_options.py +0 -0
  86. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_numbers.py +0 -0
  87. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_path_options.py +0 -0
  88. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_edge_strings.py +0 -0
  89. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_empty_collection_items.py +0 -0
  90. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_empty_strings.py +0 -0
  91. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_enum.py +0 -0
  92. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_errors.py +0 -0
  93. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_field_constraints.py +0 -0
  94. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_future_annotations.py +0 -0
  95. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_inheritance.py +0 -0
  96. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_json.py +0 -0
  97. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_json_and_optional.py +0 -0
  98. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_loading.py +0 -0
  99. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_logging_config.py +0 -0
  100. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_prefix.py +0 -0
  101. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_reload.py +0 -0
  102. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_secret_redaction.py +0 -0
  103. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_secretstr_security.py +0 -0
  104. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_types.py +0 -0
  105. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_union_types.py +0 -0
  106. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_url_dsn.py +0 -0
  107. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_url_password_decoding.py +0 -0
  108. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_url_unquote.py +0 -0
  109. {dotenvmodel-0.3.1 → dotenvmodel-0.3.2}/tests/test_urls.py +0 -0
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.3.2"
3
+ }
@@ -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.1
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.
@@ -31,7 +31,7 @@ Public API:
31
31
  Exception hierarchy
32
32
  """
33
33
 
34
- __version__ = "0.3.1"
34
+ __version__ = "0.3.2"
35
35
  __author__ = "AZX, PBC."
36
36
  __email__ = "oss@azx.io"
37
37
  __license__ = "MIT"
@@ -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.1"
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.4",
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.20",
58
- "ty>=0.0.56",
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