dotenvmodel 0.3.0__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.0 → dotenvmodel-0.3.2}/.github/workflows/ci.yml +1 -1
- dotenvmodel-0.3.2/.release-please-manifest.json +3 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/CHANGELOG.md +14 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/PKG-INFO +44 -1
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/README.md +43 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/__init__.py +1 -1
- dotenvmodel-0.3.2/dotenvmodel/_redaction.py +260 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/config.py +39 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/describe/formatters.py +26 -2
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/exceptions.py +13 -4
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/types.py +14 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/validation.py +28 -15
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/pyproject.toml +4 -4
- dotenvmodel-0.3.2/tests/test_nested_config.py +238 -0
- dotenvmodel-0.3.2/tests/test_secret_redaction.py +632 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/uv.lock +174 -186
- dotenvmodel-0.3.0/.release-please-manifest.json +0 -3
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/.github/CODEOWNERS +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/.github/dependabot.yml +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/.github/pull_request_template.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/.github/workflows/docs.yml +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/.github/workflows/publish.yml +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/.github/workflows/release-please.yml +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/.gitignore +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/.python-version +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/CODE_OF_CONDUCT.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/CONTRIBUTING.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/LICENSE +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/Makefile +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/SECURITY.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/coercion.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/config.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/constants.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/describe-formatters.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/describe-renderers.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/describe.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/dotenvmodel.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/exceptions.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/fields.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/loading.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/logging-config.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/metaclass.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/types.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/api-reference/validation.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/changelog.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/examples/complete-app.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/getting-started/installation.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/getting-started/quick-start.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/guides/configuration-docs.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/guides/error-handling.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/guides/fields.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/guides/loading.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/guides/logging.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/guides/prefixes.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/guides/types.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/guides/validation.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/docs/index.md +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/_constants.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/coercion.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/describe/__init__.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/describe/renderers.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/fields.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/loading.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/logging_config.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/metaclass.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/dotenvmodel/py.typed +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/examples/advanced_types.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/examples/basic_usage.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/examples/describe_documentation.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/examples/generate_env_example.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/examples/logging_example.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/mkdocs.yml +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/release-please-config.json +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/__init__.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/conftest.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_basic.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_coercion.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_collection_validators.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_describe.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_edge_adoption.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_edge_datetime.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_edge_describe_output.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_edge_enum.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_edge_field_options.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_edge_numbers.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_edge_path_options.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_edge_strings.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_empty_collection_items.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_empty_strings.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_enum.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_errors.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_field_constraints.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_future_annotations.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_inheritance.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_json.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_json_and_optional.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_loading.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_logging_config.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_prefix.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_reload.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_secretstr_security.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_types.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_union_types.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_url_dsn.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_url_password_decoding.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_url_unquote.py +0 -0
- {dotenvmodel-0.3.0 → dotenvmodel-0.3.2}/tests/test_urls.py +0 -0
|
@@ -133,7 +133,7 @@ jobs:
|
|
|
133
133
|
cd /tmp && $GITHUB_WORKSPACE/test-src-env/bin/python -c "import dotenvmodel; print(dotenvmodel.__version__)"
|
|
134
134
|
|
|
135
135
|
- name: Upload build artifacts
|
|
136
|
-
uses: actions/upload-artifact@
|
|
136
|
+
uses: actions/upload-artifact@v7
|
|
137
137
|
with:
|
|
138
138
|
name: dist
|
|
139
139
|
path: dist/
|
|
@@ -5,6 +5,20 @@ 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
|
+
|
|
15
|
+
## [0.3.1](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.0...v0.3.1) (2026-07-13)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Bug Fixes
|
|
19
|
+
|
|
20
|
+
* redact credentials in DSN repr, errors, docs, and secret exception chains ([#29](https://github.com/AZX-PBC-OSS/dotenvmodel/issues/29)) ([cfdd218](https://github.com/AZX-PBC-OSS/dotenvmodel/commit/cfdd218ff157d41f7af06c1beaf4c539080fa2dd))
|
|
21
|
+
|
|
8
22
|
## [0.3.0](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.2.0...v0.3.0) (2026-07-09)
|
|
9
23
|
|
|
10
24
|
|
|
@@ -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.
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Credential-redaction helpers shared across display paths.
|
|
2
|
+
|
|
3
|
+
Kept dependency-free (stdlib only) so both ``exceptions`` and ``types`` can
|
|
4
|
+
import it without creating an import cycle.
|
|
5
|
+
|
|
6
|
+
Masking philosophy — deliberately asymmetric. These helpers run only on
|
|
7
|
+
error/describe/``repr`` display paths, where over-masking a benign value
|
|
8
|
+
(``sort_key=name`` shown as ``sort_key=***``) costs a little debug context,
|
|
9
|
+
while under-masking leaks a credential. So the classifier keeps NO benign
|
|
10
|
+
carve-out lists: any key with a secret-word token is masked, and the word
|
|
11
|
+
list is the single place to maintain.
|
|
12
|
+
|
|
13
|
+
Known, documented non-goals (use ``SecretStr`` for these):
|
|
14
|
+
- secrets embedded in a URL *path* segment (Slack webhooks, ``/bot<token>/``);
|
|
15
|
+
- plain non-URL secret values and secrets inside containers/JSON bodies;
|
|
16
|
+
- ``user:pw@host`` shapes buried mid-string (only a leading one is masked).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from urllib.parse import unquote_plus, urlparse
|
|
21
|
+
|
|
22
|
+
__all__ = ["redact_url_password"]
|
|
23
|
+
|
|
24
|
+
_MASK = "***"
|
|
25
|
+
|
|
26
|
+
# A key is sensitive when ANY of its separator/camelCase tokens is one of
|
|
27
|
+
# these words, or the separator-stripped whole key matches (``session_id`` ->
|
|
28
|
+
# ``sessionid``, ``connection_string`` -> ``connectionstring``). Exact token
|
|
29
|
+
# equality keeps lookalikes benign: ``keyword``/``monkey``/``authors``/
|
|
30
|
+
# ``oauth``/``bypass``/``passwordless`` never match.
|
|
31
|
+
_SECRET_WORDS = frozenset(
|
|
32
|
+
{
|
|
33
|
+
"password",
|
|
34
|
+
"passwd",
|
|
35
|
+
"pwd",
|
|
36
|
+
"pass",
|
|
37
|
+
"passphrase",
|
|
38
|
+
"passcode",
|
|
39
|
+
"secret",
|
|
40
|
+
"credential",
|
|
41
|
+
"credentials",
|
|
42
|
+
"apikey",
|
|
43
|
+
"key",
|
|
44
|
+
"token",
|
|
45
|
+
"auth",
|
|
46
|
+
"authorization",
|
|
47
|
+
"assertion",
|
|
48
|
+
"signature",
|
|
49
|
+
"sig",
|
|
50
|
+
"hmac",
|
|
51
|
+
"jwt",
|
|
52
|
+
"bearer",
|
|
53
|
+
"sessionid",
|
|
54
|
+
"jsessionid",
|
|
55
|
+
"phpsessid",
|
|
56
|
+
"sid",
|
|
57
|
+
"session",
|
|
58
|
+
"otp",
|
|
59
|
+
"totp",
|
|
60
|
+
"sas",
|
|
61
|
+
"saml",
|
|
62
|
+
"verifier",
|
|
63
|
+
"challenge",
|
|
64
|
+
"pin",
|
|
65
|
+
"cvv",
|
|
66
|
+
"cvc",
|
|
67
|
+
"pat",
|
|
68
|
+
"license",
|
|
69
|
+
"dsn",
|
|
70
|
+
"connectionstring",
|
|
71
|
+
"connstr",
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Credential only as the ENTIRE key: OAuth's bare ``?code=`` masks, while
|
|
76
|
+
# ``status_code``/``error_code`` (debug-critical) stay visible.
|
|
77
|
+
_SECRET_WHOLE_ONLY = frozenset({"code"})
|
|
78
|
+
|
|
79
|
+
# Glued (separator-less) suffixes, e.g. ``sslpassword`` / ``xapikey``.
|
|
80
|
+
_SECRET_SUFFIXES = (
|
|
81
|
+
"password",
|
|
82
|
+
"passphrase",
|
|
83
|
+
"passcode",
|
|
84
|
+
"signature",
|
|
85
|
+
"apikey",
|
|
86
|
+
"connectionstring",
|
|
87
|
+
"connstr",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Prefixes that make a glued (separator-less) ``*key``/``*token``/``*secret``
|
|
91
|
+
# compound a credential: ``secretkey``, ``accesstoken``, ``clientsecret``.
|
|
92
|
+
_CREDENTIAL_PREFIXES = frozenset(
|
|
93
|
+
{
|
|
94
|
+
"secret",
|
|
95
|
+
"access",
|
|
96
|
+
"private",
|
|
97
|
+
"api",
|
|
98
|
+
"client",
|
|
99
|
+
"signing",
|
|
100
|
+
"encryption",
|
|
101
|
+
"refresh",
|
|
102
|
+
"session",
|
|
103
|
+
"shared",
|
|
104
|
+
"master",
|
|
105
|
+
"auth",
|
|
106
|
+
"bearer",
|
|
107
|
+
"consumer",
|
|
108
|
+
"sas",
|
|
109
|
+
"ssl",
|
|
110
|
+
"account",
|
|
111
|
+
"storage",
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Split camelCase and ACRONYMBoundaries (HMACKey -> HMAC, Key).
|
|
116
|
+
_CAMEL = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
|
|
117
|
+
_SEPARATORS = re.compile(r"[-_.]+")
|
|
118
|
+
# Fallback for unparseable URLs: mask the userinfo password (scheme://user:PW@).
|
|
119
|
+
# The password class allows '@'/':' and backtracks to the last '@' before the
|
|
120
|
+
# path, so an unescaped '@' in the password is still fully masked.
|
|
121
|
+
_USERINFO_PW = re.compile(r"(://[^:/?#@\s]*:)[^/?#\s]+(@)")
|
|
122
|
+
# Scheme-less userinfo (the forgot-the-scheme typo: ``dbuser:pw@host/db``).
|
|
123
|
+
# urlparse reads ``dbuser`` as the scheme there, so the netloc path never
|
|
124
|
+
# fires; anchored at the start so prose with colons is left alone.
|
|
125
|
+
_SCHEMELESS_PW = re.compile(r"^([^:/?#@\s]+:)[^/?#\s]+(@)(?=[^/?#\s@]*[/:])")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _is_sensitive_key(key: str) -> bool:
|
|
129
|
+
"""True if a query/fragment/params key names a credential.
|
|
130
|
+
|
|
131
|
+
Any secret-word token masks (``access_token``, ``db_pass``,
|
|
132
|
+
``api_key_v2beta`` — the qualifier tokens don't hide the match), plus a
|
|
133
|
+
stripped-whole-key check for separator-spanning words and a suffix/prefix
|
|
134
|
+
check for glued forms like ``sslpassword``/``secretkey``. No benign
|
|
135
|
+
carve-outs: ``sort_key``/``page_token`` mask too, by design (see module
|
|
136
|
+
docstring).
|
|
137
|
+
"""
|
|
138
|
+
raw = unquote_plus(key).strip()
|
|
139
|
+
if not raw:
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
k = _CAMEL.sub("_", raw).lower()
|
|
143
|
+
tokens = [t for t in _SEPARATORS.split(k) if t]
|
|
144
|
+
if not tokens:
|
|
145
|
+
return False
|
|
146
|
+
stripped = "".join(tokens)
|
|
147
|
+
|
|
148
|
+
if stripped in _SECRET_WHOLE_ONLY:
|
|
149
|
+
return True
|
|
150
|
+
# A secret-word token (or its plural: passwords/tokens/keys).
|
|
151
|
+
if any(t in _SECRET_WORDS or (t.endswith("s") and t[:-1] in _SECRET_WORDS) for t in tokens):
|
|
152
|
+
return True
|
|
153
|
+
# A secret word spanning separators (session_id -> sessionid).
|
|
154
|
+
if stripped in _SECRET_WORDS:
|
|
155
|
+
return True
|
|
156
|
+
if _is_glued_credential(stripped):
|
|
157
|
+
return True
|
|
158
|
+
return any(stripped.endswith(sfx) for sfx in _SECRET_SUFFIXES)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _is_glued_credential(stripped: str) -> bool:
|
|
162
|
+
"""True for separator-less compounds like ``secretkey`` / ``accesstoken``."""
|
|
163
|
+
for suffix in ("key", "token", "secret", "password"):
|
|
164
|
+
if stripped.endswith(suffix) and len(stripped) > len(suffix):
|
|
165
|
+
prefix = stripped[: -len(suffix)]
|
|
166
|
+
# ``secretaccesskey`` -> prefix ``secretaccess`` starts with ``secret``.
|
|
167
|
+
if any(prefix.startswith(p) for p in _CREDENTIAL_PREFIXES):
|
|
168
|
+
return True
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _redact_pairs(component: str) -> tuple[str, bool]:
|
|
173
|
+
"""Mask sensitive values in an ``&``-separated key=value string.
|
|
174
|
+
|
|
175
|
+
Per the WHATWG URL standard, ``&`` is the only separator; a ``;`` is data
|
|
176
|
+
and stays inside its value. A sensitive key masks its whole value
|
|
177
|
+
(including any ``;`` tail). Defensively, a legacy ``;``-glued sub-pair
|
|
178
|
+
inside a *benign* key's value (``db=0;password=x``) is also masked, pair
|
|
179
|
+
by pair. Non-sensitive pairs are preserved byte-for-byte. Returns the
|
|
180
|
+
(possibly rewritten) component and whether anything changed.
|
|
181
|
+
"""
|
|
182
|
+
changed = False
|
|
183
|
+
parts = component.split("&")
|
|
184
|
+
for i, part in enumerate(parts):
|
|
185
|
+
key, sep, value = part.partition("=")
|
|
186
|
+
if sep and value and _is_sensitive_key(key):
|
|
187
|
+
parts[i] = f"{key}={_MASK}"
|
|
188
|
+
changed = True
|
|
189
|
+
continue
|
|
190
|
+
if ";" in part:
|
|
191
|
+
subs = part.split(";")
|
|
192
|
+
sub_changed = False
|
|
193
|
+
for j, sub in enumerate(subs):
|
|
194
|
+
skey, ssep, svalue = sub.partition("=")
|
|
195
|
+
if ssep and svalue and _is_sensitive_key(skey):
|
|
196
|
+
subs[j] = f"{skey}={_MASK}"
|
|
197
|
+
sub_changed = True
|
|
198
|
+
if sub_changed:
|
|
199
|
+
parts[i] = ";".join(subs)
|
|
200
|
+
changed = True
|
|
201
|
+
return ("&".join(parts), changed) if changed else (component, False)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def redact_url_password(value: str) -> str:
|
|
205
|
+
"""Return ``value`` with any URL credential replaced by ``***``.
|
|
206
|
+
|
|
207
|
+
Masks the userinfo password (``user:pass@host`` -> ``user:***@host``,
|
|
208
|
+
including the scheme-less typo form ``user:pass@host`` with no ``//``)
|
|
209
|
+
and the value of any sensitive key in the query string, fragment, or
|
|
210
|
+
``;``-params component (e.g. ``?password=`` / ``#access_token=`` /
|
|
211
|
+
``/path;password=``). Host, port, and non-sensitive parameters are
|
|
212
|
+
preserved verbatim. A string without any credential-shaped part is
|
|
213
|
+
returned unchanged, so this is safe to call on arbitrary values.
|
|
214
|
+
"""
|
|
215
|
+
# Scheme-less userinfo: urlparse would read the username as the scheme, so
|
|
216
|
+
# mask a leading `user:pw@host/…` up front (anchored + a path/port lookahead
|
|
217
|
+
# so `https://…` and email-shaped `cc:john@example.com` are left alone). Done
|
|
218
|
+
# unconditionally so a later `://` (e.g. in a redirect param) can't skip it.
|
|
219
|
+
prefix_masked = _SCHEMELESS_PW.sub(rf"\1{_MASK}\2", value)
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
parsed = urlparse(prefix_masked)
|
|
223
|
+
except ValueError:
|
|
224
|
+
# Malformed URL (e.g. bad IPv6). urlparse can't help, but neither the
|
|
225
|
+
# userinfo password nor a query/fragment secret may leak, so mask the
|
|
226
|
+
# userinfo by regex and redact the (splittable) query and fragment.
|
|
227
|
+
masked = _USERINFO_PW.sub(rf"\1{_MASK}\2", prefix_masked)
|
|
228
|
+
head, hsep, frag = masked.partition("#")
|
|
229
|
+
head, qsep, query = head.partition("?")
|
|
230
|
+
head, psep, params = head.partition(";")
|
|
231
|
+
if psep:
|
|
232
|
+
params = _redact_pairs(params)[0]
|
|
233
|
+
if qsep:
|
|
234
|
+
query = _redact_pairs(query)[0]
|
|
235
|
+
if hsep:
|
|
236
|
+
frag = _redact_pairs(frag)[0]
|
|
237
|
+
return head + psep + params + qsep + query + hsep + frag
|
|
238
|
+
|
|
239
|
+
netloc = parsed.netloc
|
|
240
|
+
changed = prefix_masked != value
|
|
241
|
+
|
|
242
|
+
if parsed.password:
|
|
243
|
+
userinfo, _, hostpart = netloc.rpartition("@")
|
|
244
|
+
username = userinfo.partition(":")[0]
|
|
245
|
+
netloc = f"{username}:{_MASK}@{hostpart}"
|
|
246
|
+
changed = True
|
|
247
|
+
|
|
248
|
+
params, params_changed = (
|
|
249
|
+
_redact_pairs(parsed.params) if parsed.params else (parsed.params, False)
|
|
250
|
+
)
|
|
251
|
+
query, query_changed = _redact_pairs(parsed.query) if parsed.query else (parsed.query, False)
|
|
252
|
+
fragment, frag_changed = (
|
|
253
|
+
_redact_pairs(parsed.fragment) if parsed.fragment else (parsed.fragment, False)
|
|
254
|
+
)
|
|
255
|
+
changed = changed or params_changed or query_changed or frag_changed
|
|
256
|
+
|
|
257
|
+
if not changed:
|
|
258
|
+
return value
|
|
259
|
+
|
|
260
|
+
return parsed._replace(netloc=netloc, params=params, query=query, fragment=fragment).geturl()
|
|
@@ -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:
|
|
@@ -13,8 +13,9 @@ from typing import Any, Union, get_args, get_origin
|
|
|
13
13
|
|
|
14
14
|
from typing_extensions import TypeForm
|
|
15
15
|
|
|
16
|
+
from dotenvmodel._redaction import redact_url_password
|
|
16
17
|
from dotenvmodel.fields import _MISSING, FieldInfo
|
|
17
|
-
from dotenvmodel.types import SecretStr
|
|
18
|
+
from dotenvmodel.types import BaseDsn, SecretStr
|
|
18
19
|
|
|
19
20
|
# Maximum column widths to prevent unbounded table growth
|
|
20
21
|
MAX_WIDTHS = {
|
|
@@ -317,6 +318,23 @@ def format_constraints(
|
|
|
317
318
|
return ", ".join(constraints) if constraints else "-"
|
|
318
319
|
|
|
319
320
|
|
|
321
|
+
def _union_members(field_type: TypeForm[Any]) -> list[Any]:
|
|
322
|
+
"""Return the non-None members of an Optional/Union, else ``[field_type]``.
|
|
323
|
+
|
|
324
|
+
Handles multi-member unions (``PostgresDsn | RedisDsn | None``) so a DSN or
|
|
325
|
+
SecretStr anywhere in the union is still recognised for redaction.
|
|
326
|
+
"""
|
|
327
|
+
origin = get_origin(field_type)
|
|
328
|
+
if origin is types.UnionType or origin is Union:
|
|
329
|
+
return [a for a in get_args(field_type) if a is not type(None)]
|
|
330
|
+
return [field_type]
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _is_type_in_union(field_type: TypeForm[Any], target: type) -> bool:
|
|
334
|
+
"""True if any member of ``field_type`` is a subclass of ``target``."""
|
|
335
|
+
return any(isinstance(m, type) and issubclass(m, target) for m in _union_members(field_type))
|
|
336
|
+
|
|
337
|
+
|
|
320
338
|
def format_default(field_info: FieldInfo, field_type: TypeForm[Any], truncate: bool = True) -> str:
|
|
321
339
|
"""Format default value for display."""
|
|
322
340
|
if field_info.default is _MISSING and field_info.default_factory is None:
|
|
@@ -340,9 +358,15 @@ def format_default(field_info: FieldInfo, field_type: TypeForm[Any], truncate: b
|
|
|
340
358
|
if isinstance(default, Enum):
|
|
341
359
|
return str(default.value)
|
|
342
360
|
|
|
343
|
-
|
|
361
|
+
# Recognise DSN/SecretStr even inside an Optional/multi-member Union
|
|
362
|
+
# (e.g. `PostgresDsn | RedisDsn | None`) so nested defaults are redacted.
|
|
363
|
+
if _is_type_in_union(field_type, SecretStr):
|
|
344
364
|
return "<secret>"
|
|
345
365
|
|
|
366
|
+
# DSN defaults may embed credentials; redact the password before display.
|
|
367
|
+
if _is_type_in_union(field_type, BaseDsn) and isinstance(default, str):
|
|
368
|
+
return f'"{redact_url_password(str.__str__(default))}"'
|
|
369
|
+
|
|
346
370
|
if isinstance(default, str):
|
|
347
371
|
if truncate and len(default) > TRUNCATE_THRESHOLD_SHORT:
|
|
348
372
|
return f'"{default[: TRUNCATE_THRESHOLD_SHORT - 3]}..."'
|
|
@@ -4,6 +4,15 @@ from typing import Any
|
|
|
4
4
|
|
|
5
5
|
from typing_extensions import TypeForm
|
|
6
6
|
|
|
7
|
+
from dotenvmodel._redaction import redact_url_password
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _value_repr(value: Any) -> str:
|
|
11
|
+
"""Repr a field value for error messages, redacting URL passwords."""
|
|
12
|
+
if isinstance(value, str):
|
|
13
|
+
return repr(redact_url_password(str.__str__(value)))
|
|
14
|
+
return repr(value)
|
|
15
|
+
|
|
7
16
|
|
|
8
17
|
class DotEnvModelError(Exception):
|
|
9
18
|
"""Base exception for all dotenvmodel errors.
|
|
@@ -83,7 +92,7 @@ class ValidationError(DotEnvModelError):
|
|
|
83
92
|
def _format_message(self) -> str:
|
|
84
93
|
"""Format a detailed error message."""
|
|
85
94
|
msg = f"Field '{self.field_name}' validation failed:\n"
|
|
86
|
-
msg += f" Value: {self.value
|
|
95
|
+
msg += f" Value: {_value_repr(self.value)}\n"
|
|
87
96
|
if self.field_type:
|
|
88
97
|
type_name = getattr(self.field_type, "__name__", str(self.field_type))
|
|
89
98
|
msg += f" Expected type: {type_name}\n"
|
|
@@ -182,7 +191,7 @@ class TypeCoercionError(ValidationError):
|
|
|
182
191
|
if self.field_type:
|
|
183
192
|
type_name = getattr(self.field_type, "__name__", str(self.field_type))
|
|
184
193
|
msg = f"TypeCoercionError: Failed to coerce field '{self.field_name}' to type {type_name}.\n\n"
|
|
185
|
-
msg += f"Value: {self.value
|
|
194
|
+
msg += f"Value: {_value_repr(self.value)}\n"
|
|
186
195
|
msg += f"Environment variable: {self.env_var_name}\n"
|
|
187
196
|
msg += f"Error: {self.error_msg}\n"
|
|
188
197
|
msg += f"Hint: Ensure {self.env_var_name} contains a valid {type_name}"
|
|
@@ -239,7 +248,7 @@ class ConstraintViolationError(ValidationError):
|
|
|
239
248
|
def _format_message(self) -> str:
|
|
240
249
|
"""Format a detailed error message for constraint violations."""
|
|
241
250
|
msg = f"ConstraintViolationError: Field '{self.field_name}' violates constraint.\n\n"
|
|
242
|
-
msg += f"Value: {self.value
|
|
251
|
+
msg += f"Value: {_value_repr(self.value)}\n"
|
|
243
252
|
msg += f"Constraint: {self.constraint}\n"
|
|
244
253
|
msg += f"Error: {self.error_msg}\n"
|
|
245
254
|
msg += f"Hint: Set {self.env_var_name} to a value that satisfies the constraint"
|
|
@@ -284,7 +293,7 @@ class MultipleValidationErrors(DotEnvModelError):
|
|
|
284
293
|
msg += f"{i}. {error.__class__.__name__}: {error.error_msg}\n"
|
|
285
294
|
msg += f" Field: {error.field_name}\n"
|
|
286
295
|
if error.value is not None:
|
|
287
|
-
msg += f" Value: {error.value
|
|
296
|
+
msg += f" Value: {_value_repr(error.value)}\n"
|
|
288
297
|
msg += f" Environment variable: {error.env_var_name}\n"
|
|
289
298
|
if hasattr(error, "constraint") and error.constraint is not None: # type: ignore[attr-defined]
|
|
290
299
|
msg += f" Constraint: {error.constraint}\n" # type: ignore[attr-defined]
|
|
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, TypeVar
|
|
|
8
8
|
from urllib.parse import ParseResult, unquote, urlparse
|
|
9
9
|
from uuid import UUID
|
|
10
10
|
|
|
11
|
+
from dotenvmodel._redaction import redact_url_password
|
|
11
12
|
from dotenvmodel.exceptions import TypeCoercionError
|
|
12
13
|
|
|
13
14
|
_T = TypeVar("_T")
|
|
@@ -146,6 +147,19 @@ class BaseDsn(str):
|
|
|
146
147
|
|
|
147
148
|
return str.__new__(cls, value)
|
|
148
149
|
|
|
150
|
+
def __repr__(self) -> str:
|
|
151
|
+
"""Return a redacted ``repr`` with any password masked.
|
|
152
|
+
|
|
153
|
+
Only ``repr`` is overridden. ``str(dsn)`` and the raw buffer remain the
|
|
154
|
+
real connection string so the DSN stays usable with database drivers
|
|
155
|
+
(``create_engine(str(url))``, ``redis.from_url(str(url))``, etc.). This
|
|
156
|
+
masks the accidental-display path (``repr(config)``, debuggers, ``%r``
|
|
157
|
+
logging) without breaking functionality. Values that must never appear
|
|
158
|
+
in serialized output (``json.dumps``, ``.encode()``) should use
|
|
159
|
+
``SecretStr`` instead.
|
|
160
|
+
"""
|
|
161
|
+
return repr(redact_url_password(str.__str__(self)))
|
|
162
|
+
|
|
149
163
|
@property
|
|
150
164
|
def parsed(self) -> ParseResult:
|
|
151
165
|
"""Get the parsed URL components."""
|