dotenvmodel 0.3.0__tar.gz → 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/workflows/ci.yml +1 -1
  2. dotenvmodel-0.3.1/.release-please-manifest.json +3 -0
  3. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/CHANGELOG.md +7 -0
  4. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/PKG-INFO +1 -1
  5. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/__init__.py +1 -1
  6. dotenvmodel-0.3.1/dotenvmodel/_redaction.py +260 -0
  7. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/describe/formatters.py +26 -2
  8. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/exceptions.py +13 -4
  9. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/types.py +14 -0
  10. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/validation.py +28 -15
  11. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/pyproject.toml +1 -1
  12. dotenvmodel-0.3.1/tests/test_secret_redaction.py +632 -0
  13. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/uv.lock +1 -1
  14. dotenvmodel-0.3.0/.release-please-manifest.json +0 -3
  15. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/CODEOWNERS +0 -0
  16. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  17. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  18. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/dependabot.yml +0 -0
  19. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/pull_request_template.md +0 -0
  20. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/workflows/docs.yml +0 -0
  21. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/workflows/publish.yml +0 -0
  22. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.github/workflows/release-please.yml +0 -0
  23. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.gitignore +0 -0
  24. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/.python-version +0 -0
  25. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/CODE_OF_CONDUCT.md +0 -0
  26. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/CONTRIBUTING.md +0 -0
  27. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/LICENSE +0 -0
  28. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/Makefile +0 -0
  29. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/README.md +0 -0
  30. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/SECURITY.md +0 -0
  31. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/coercion.md +0 -0
  32. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/config.md +0 -0
  33. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/constants.md +0 -0
  34. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/describe-formatters.md +0 -0
  35. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/describe-renderers.md +0 -0
  36. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/describe.md +0 -0
  37. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/dotenvmodel.md +0 -0
  38. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/exceptions.md +0 -0
  39. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/fields.md +0 -0
  40. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/loading.md +0 -0
  41. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/logging-config.md +0 -0
  42. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/metaclass.md +0 -0
  43. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/types.md +0 -0
  44. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/api-reference/validation.md +0 -0
  45. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/changelog.md +0 -0
  46. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/examples/complete-app.md +0 -0
  47. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/getting-started/installation.md +0 -0
  48. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/getting-started/quick-start.md +0 -0
  49. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/configuration-docs.md +0 -0
  50. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/error-handling.md +0 -0
  51. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/fields.md +0 -0
  52. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/loading.md +0 -0
  53. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/logging.md +0 -0
  54. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/prefixes.md +0 -0
  55. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/types.md +0 -0
  56. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/guides/validation.md +0 -0
  57. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/docs/index.md +0 -0
  58. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/_constants.py +0 -0
  59. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/coercion.py +0 -0
  60. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/config.py +0 -0
  61. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/describe/__init__.py +0 -0
  62. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/describe/renderers.py +0 -0
  63. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/fields.py +0 -0
  64. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/loading.py +0 -0
  65. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/logging_config.py +0 -0
  66. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/metaclass.py +0 -0
  67. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/dotenvmodel/py.typed +0 -0
  68. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/examples/advanced_types.py +0 -0
  69. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/examples/basic_usage.py +0 -0
  70. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/examples/describe_documentation.py +0 -0
  71. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/examples/generate_env_example.py +0 -0
  72. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/examples/logging_example.py +0 -0
  73. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/mkdocs.yml +0 -0
  74. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/release-please-config.json +0 -0
  75. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/__init__.py +0 -0
  76. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/conftest.py +0 -0
  77. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_basic.py +0 -0
  78. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_coercion.py +0 -0
  79. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_collection_validators.py +0 -0
  80. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_describe.py +0 -0
  81. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_adoption.py +0 -0
  82. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_datetime.py +0 -0
  83. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_describe_output.py +0 -0
  84. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_enum.py +0 -0
  85. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_field_options.py +0 -0
  86. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_numbers.py +0 -0
  87. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_path_options.py +0 -0
  88. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_edge_strings.py +0 -0
  89. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_empty_collection_items.py +0 -0
  90. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_empty_strings.py +0 -0
  91. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_enum.py +0 -0
  92. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_errors.py +0 -0
  93. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_field_constraints.py +0 -0
  94. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_future_annotations.py +0 -0
  95. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_inheritance.py +0 -0
  96. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_json.py +0 -0
  97. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_json_and_optional.py +0 -0
  98. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_loading.py +0 -0
  99. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_logging_config.py +0 -0
  100. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_prefix.py +0 -0
  101. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_reload.py +0 -0
  102. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_secretstr_security.py +0 -0
  103. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_types.py +0 -0
  104. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_union_types.py +0 -0
  105. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_url_dsn.py +0 -0
  106. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_url_password_decoding.py +0 -0
  107. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/tests/test_url_unquote.py +0 -0
  108. {dotenvmodel-0.3.0 → dotenvmodel-0.3.1}/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@v4
136
+ uses: actions/upload-artifact@v7
137
137
  with:
138
138
  name: dist
139
139
  path: dist/
@@ -0,0 +1,3 @@
1
+ {
2
+ ".": "0.3.1"
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.1](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.3.0...v0.3.1) (2026-07-13)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * 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))
14
+
8
15
  ## [0.3.0](https://github.com/AZX-PBC-OSS/dotenvmodel/compare/v0.2.0...v0.3.0) (2026-07-09)
9
16
 
10
17
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dotenvmodel
3
- Version: 0.3.0
3
+ Version: 0.3.1
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
@@ -31,7 +31,7 @@ Public API:
31
31
  Exception hierarchy
32
32
  """
33
33
 
34
- __version__ = "0.3.0"
34
+ __version__ = "0.3.1"
35
35
  __author__ = "AZX, PBC."
36
36
  __email__ = "oss@azx.io"
37
37
  __license__ = "MIT"
@@ -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()
@@ -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
- if isinstance(field_type, type) and issubclass(field_type, SecretStr):
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!r}\n"
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!r}\n"
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!r}\n"
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!r}\n"
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."""
@@ -49,16 +49,16 @@ def validate_field(field_name: str, value: Any, field_info: FieldInfo, env_var_n
49
49
  if isinstance(value, str):
50
50
  _validate_string(field_name, value, field_info, env_var_name)
51
51
  elif isinstance(value, SecretStr):
52
- try:
53
- _validate_string(field_name, value.get_secret_value(), field_info, env_var_name)
54
- except ConstraintViolationError as e:
55
- raise ConstraintViolationError(
56
- field_name=e.field_name,
57
- value=value,
58
- constraint=e.constraint,
59
- error_msg=e.error_msg,
60
- env_var_name=e.env_var_name,
61
- ) from e
52
+ # Check the plaintext for length/regex, but report the masked SecretStr.
53
+ # No plaintext-bearing exception is ever constructed, so nothing can
54
+ # leak via the exception chain (__cause__/__context__).
55
+ _validate_string(
56
+ field_name,
57
+ value.get_secret_value(),
58
+ field_info,
59
+ env_var_name,
60
+ report_value=value,
61
+ )
62
62
 
63
63
  # Choice validation (works for any type)
64
64
  if field_info.choices is not None:
@@ -114,12 +114,25 @@ def _validate_numeric(
114
114
  )
115
115
 
116
116
 
117
- def _validate_string(field_name: str, value: str, field_info: FieldInfo, env_var_name: str) -> None:
118
- """Validate string constraints (min_length, max_length, regex)."""
117
+ def _validate_string(
118
+ field_name: str,
119
+ value: str,
120
+ field_info: FieldInfo,
121
+ env_var_name: str,
122
+ report_value: object | None = None,
123
+ ) -> None:
124
+ """Validate string constraints (min_length, max_length, regex).
125
+
126
+ ``value`` is the plaintext used for the checks; ``report_value`` (when given,
127
+ e.g. the masking ``SecretStr`` wrapper) is what appears in any raised error,
128
+ so the plaintext never enters an exception.
129
+ """
130
+ reported = value if report_value is None else report_value
131
+
119
132
  if field_info.min_length is not None and len(value) < field_info.min_length:
120
133
  raise ConstraintViolationError(
121
134
  field_name=field_name,
122
- value=value,
135
+ value=reported,
123
136
  constraint=f"min_length={field_info.min_length}",
124
137
  error_msg=f"String must be at least {field_info.min_length} characters long",
125
138
  env_var_name=env_var_name,
@@ -128,7 +141,7 @@ def _validate_string(field_name: str, value: str, field_info: FieldInfo, env_var
128
141
  if field_info.max_length is not None and len(value) > field_info.max_length:
129
142
  raise ConstraintViolationError(
130
143
  field_name=field_name,
131
- value=value,
144
+ value=reported,
132
145
  constraint=f"max_length={field_info.max_length}",
133
146
  error_msg=f"String must be at most {field_info.max_length} characters long",
134
147
  env_var_name=env_var_name,
@@ -141,7 +154,7 @@ def _validate_string(field_name: str, value: str, field_info: FieldInfo, env_var
141
154
  ):
142
155
  raise ConstraintViolationError(
143
156
  field_name=field_name,
144
- value=value,
157
+ value=reported,
145
158
  constraint=f"regex={field_info.regex!r}",
146
159
  error_msg=f"String must match pattern: {field_info.regex}",
147
160
  env_var_name=env_var_name,
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dotenvmodel"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Type-safe environment configuration with automatic .env file loading"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"