DataExcept 0.4.0__tar.gz → 0.4.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 (39) hide show
  1. {dataexcept-0.4.0 → dataexcept-0.4.2}/CHANGELOG.md +165 -2
  2. {dataexcept-0.4.0 → dataexcept-0.4.2}/CITATION.cff +2 -2
  3. {dataexcept-0.4.0 → dataexcept-0.4.2}/PKG-INFO +11 -10
  4. {dataexcept-0.4.0 → dataexcept-0.4.2}/README.md +8 -8
  5. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/__init__.py +6 -2
  6. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/_validation.py +7 -1
  7. dataexcept-0.4.2/dataexcept/base.py +195 -0
  8. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/dataengineering_exceptions.py +2 -1
  9. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/datascience_exceptions/ingestion.py +2 -1
  10. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/datascience_exceptions/operations.py +2 -1
  11. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/datascience_exceptions/training.py +3 -1
  12. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/notification.py +7 -2
  13. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/io_exceptions.py +4 -3
  14. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/logging_helpers.py +41 -1
  15. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/pandas_exceptions.py +7 -5
  16. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/pipeline_exceptions.py +6 -4
  17. dataexcept-0.4.2/dataexcept/redaction.py +240 -0
  18. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/security_exceptions.py +4 -2
  19. {dataexcept-0.4.0 → dataexcept-0.4.2}/pyproject.toml +7 -2
  20. dataexcept-0.4.0/dataexcept/base.py +0 -67
  21. dataexcept-0.4.0/dataexcept/redaction.py +0 -114
  22. {dataexcept-0.4.0 → dataexcept-0.4.2}/LICENSE +0 -0
  23. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/__main__.py +0 -0
  24. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/_deprecation.py +0 -0
  25. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/database_exceptions.py +0 -0
  26. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/datascience_exceptions/__init__.py +0 -0
  27. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/datascience_exceptions/base.py +0 -0
  28. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/__init__.py +0 -0
  29. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/authentication.py +0 -0
  30. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/base.py +0 -0
  31. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/configuration.py +0 -0
  32. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/external.py +0 -0
  33. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/lifecycle.py +0 -0
  34. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/parsing.py +0 -0
  35. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/scheduling.py +0 -0
  36. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/validation.py +0 -0
  37. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/job_exceptions.py +0 -0
  38. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/network_exceptions.py +0 -0
  39. {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/py.typed +0 -0
@@ -7,6 +7,168 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.2] - 2026-08-25
11
+
12
+ ### Changed
13
+
14
+ - `SECURITY.md` states two further boundaries rather than leaving them to be
15
+ discovered: state attached to an exception *after* it is constructed is not
16
+ swept, and a URL nested inside a value you pass is rendered redacted but the
17
+ object itself is not rewritten. Walking and rewriting arbitrary caller data
18
+ structures would be a surprising thing for an exception library to do, and
19
+ could not be complete anyway.
20
+
21
+ ### Fixed
22
+
23
+ - **Sensitive query parameters are matched by token, not substring.** The
24
+ substring rule was wrong in both directions: it redacted `monkey`, `design`,
25
+ `assign`, `keyword` and `authors`, mangling ordinary debugging information,
26
+ while still missing `passphrase`. Parameter names are now split on
27
+ separators and camelCase and matched word by word, so `X-Amz-Signature`,
28
+ `accessToken` and `client_secret` are caught and `?monkey=bobo` is left
29
+ alone.
30
+
31
+ ### Security
32
+
33
+ - **`jwt`, `bearer`, `hmac` and `sas` are recognised as secret parameter
34
+ names.** Checked the token set against the parameters actually used by AWS
35
+ SigV4, Azure SAS, Google Cloud and OAuth 2: the signature and credential
36
+ parameters were already covered, but those four were not. `code`, `state`,
37
+ `nonce` and `client_id` are deliberately still ignored — an OAuth code is a
38
+ secret, but the name is far more often a country code, an HTTP status or a
39
+ discount code, and redacting those would destroy more than it protects.
40
+ - **Three ways a URL could still reach a message, all closed.** The redaction
41
+ boundary scrubbed the message and a handful of named fields, but 18 classes
42
+ interpolate some *other* attribute into `__str__` — a field, a column, a
43
+ resource — and a caller can put a URL in any of them. Every stored string is
44
+ now swept, so no class leaks a credential-bearing URL through its message,
45
+ its attributes or its `args`. A test fills every string argument of every
46
+ class with one and checks all three surfaces.
47
+ - Two classes assigned their attributes *after* calling `super().__init__`, so
48
+ the sweep never saw them. Both now assign first, and a test fails if any
49
+ constructor does it again.
50
+ - The URL pattern carried a `` anchor, so a URL directly following a word
51
+ character was never matched — including `feature_https://...`, the step name
52
+ `FeaturePreprocessingError` builds from its own argument.
53
+
54
+ ## [0.4.1] - 2026-08-25
55
+
56
+ ### Added
57
+
58
+ - Tests that derive the facts stated in more than one file, instead of trusting
59
+ them to be copied correctly: the citation, security policy, changelog and
60
+ checklist must all name the current version, checklist sections must be
61
+ numbered uniquely, no document may quote a test count, and the supported
62
+ Python range must agree across `requires-python`, the classifiers, the CI
63
+ matrix and the release gate.
64
+ - A contract-coverage test. The whole-hierarchy suites called `pytest.skip()`
65
+ when they could not build a class, so a class could sit outside the pickling,
66
+ message and inheritance guarantees with CI still green — and two were doing
67
+ exactly that. Skipping now requires an explicit, reasoned entry in an
68
+ exclusion table, which is empty.
69
+
70
+ - **Python 3.14 support.** `requires-python` capped at `<3.14`, so the package
71
+ refused to install on the current stable interpreter — 3.14 was released in
72
+ October 2025 and is no longer upcoming. The range is now `>=3.10,<3.15`, 3.14
73
+ is in the classifiers and the CI matrix, and `test (3.14)` joins the checks
74
+ the release workflow requires before it will build.
75
+
76
+ ### Changed
77
+
78
+ - `SECURITY.md` states the boundary precisely, including what is **not**
79
+ covered: a bare non-URL secret written into free-form text cannot be
80
+ recognised. The previous wording claimed credentials "never appear ...
81
+ whatever you pass in", which was broader than the implementation.
82
+
83
+ - The serialization and hierarchy guarantees are stated more precisely.
84
+ "Survives a process boundary intact" now says what happens to state that
85
+ cannot be serialized, and "catches anything this package raises" is now
86
+ "every operational exception" — constructors deliberately raise plain
87
+ `TypeError` for invalid arguments, which sits outside the hierarchy.
88
+
89
+ ### Fixed
90
+
91
+ - **An exception pickled by 0.4.0 could not be loaded by 0.4.1.** Restoring the
92
+ chain added three parameters to the private `_rebuild`, and an older payload
93
+ passes only three arguments — so a queued exception that outlived an upgrade,
94
+ or one sent by a worker on the previous release, raised `TypeError` on
95
+ unpickling. The new parameters carry defaults.
96
+
97
+ - The test probe fed a bare string to `Sequence[str]` parameters, so
98
+ `DataFormatError` and `DtypeMismatchError` correctly rejected it and were
99
+ silently skipped. The probe builds them properly now; nothing is skipped.
100
+ - `is_number` accepted booleans, because `bool` subclasses `int` and so
101
+ satisfies `numbers.Real`. A boolean is never a meaningful metric, threshold
102
+ or ratio, and accepting one hid a caller passing the wrong variable.
103
+ - `MergeKeyError("id", "cust_id")` silently became `['i', 'd']` — a bare string
104
+ is a sequence of strings. It now raises `TypeError`, matching
105
+ `DtypeMismatchError`, which already rejected this.
106
+ - `SECURITY.md` named 0.3.x as supported at 0.4.0, `CHECKLIST.md` was dated to
107
+ the previous release and numbered two sections 12, and the README quoted a
108
+ test count that no longer matched. `scripts/bump_version.py` now writes every
109
+ file that states the version, so they cannot drift apart by hand again.
110
+ - `black`'s target version is pinned rather than inferred from
111
+ `requires-python`. Adding 3.14 made inference pick `py314`, and black then
112
+ refused to verify its own output when run on an older interpreter — which is
113
+ what CI does.
114
+
115
+ - **Exception chaining was lost on a pickle round trip.** `__reduce__` saved
116
+ `args` and `__dict__`, but `__cause__`, `__context__` and
117
+ `__suppress_context__` are special exception state rather than `__dict__`
118
+ entries — so a wrapped exception rebuilt in another process no longer showed
119
+ what actually failed. All three are now restored explicitly. The 0.4.0 tests
120
+ did not catch this because they compared `args`, rendered text and `__dict__`
121
+ and never the chain; they now check it.
122
+ - **An exception carrying unpickleable state could not cross a process
123
+ boundary at all.** Several classes accept arbitrary caller state, so a
124
+ lambda, generator, open file or locally defined class made the whole
125
+ exception unserializable — replacing the real failure with a serialization
126
+ error about it. Such values are now replaced by an `UnpicklableValue`
127
+ describing what was there, and an unpickleable cause by an
128
+ `UnpicklableCause`, so the exception still arrives.
129
+
130
+ ### Security
131
+
132
+ - **Redaction is no longer defeated by the traceback.** `log_exception` passed
133
+ `exc_info`, so logging rendered the whole exception chain — including a
134
+ wrapped third-party exception whose own message still quoted the
135
+ credential-bearing URL. Redacting what DataExcept renders did nothing about
136
+ that. When the chain contains a URL, `log_exception` now formats the
137
+ traceback and scrubs it; every other exception keeps the structured
138
+ `exc_info` path, so nothing changes for them.
139
+ - `SECURITY.md` documents what remains outside that boundary: the wrapped
140
+ exception object is still reachable, so `traceback.print_exc()`,
141
+ `repr(exc.__dict__)` or `logger.error(..., exc_info=True)` will render its
142
+ text. A third-party exception's message is not ours to rewrite.
143
+
144
+ - **Every GitHub Action is pinned to a full-length commit SHA.** All 34
145
+ references used mutable tags, including `pypa/gh-action-pypi-publish` in the
146
+ OIDC publishing job — the step that holds the credential which uploads to
147
+ PyPI. Whoever controls an action repository can move a tag to different code
148
+ at any time; a commit SHA is the only immutable reference. Each pin carries
149
+ the version in a trailing comment so it stays reviewable and Dependabot can
150
+ still update it, and a test fails if any mutable reference reappears.
151
+
152
+ - **Credentials in a URL path are now redacted.** `redact_url` kept the whole
153
+ path, so `WebhookError` logged a Slack webhook URL — which Slack documents as
154
+ a secret in its entirety — unchanged. Where the path *is* the credential the
155
+ path is now dropped, keeping the host, which is what makes the error
156
+ actionable.
157
+ - **Sensitive parameters are matched by substring, not an exact allowlist.**
158
+ `X-Amz-Signature`, `X-Amz-Credential`, `auth_token` and `refresh_token` all
159
+ passed through before. Fragments are covered too, so an OAuth
160
+ `#access_token=` no longer survives.
161
+ - **A secret can no longer be reintroduced after redaction.** Redacting only
162
+ the structured argument left two open routes: a caller-supplied `message`,
163
+ and the text of a wrapped exception quoting the original URL. Every message
164
+ in the hierarchy now passes through one scrubbing boundary, so a URL is
165
+ redacted wherever it appears. Where the library was handed the secret
166
+ explicitly, that exact value is removed from the message as well.
167
+ - **URL-bearing fields beyond the four patched classes are redacted.**
168
+ `DataLoadingError.source` documents itself as "file path, URL" and rendered
169
+ a presigned S3 URL verbatim. Nine such fields now use `redact_if_url`, which
170
+ leaves ordinary file paths untouched.
171
+
10
172
  ## [0.4.0] - 2026-08-24
11
173
 
12
174
  ### Added
@@ -236,7 +398,6 @@ project description, and its quick-start example did not run.
236
398
  - `examples/example_usage.py` raised `TimeoutError` with keyword arguments the
237
399
  builtin does not accept — a live instance of the shadowing hazard.
238
400
 
239
-
240
401
  ## [0.1.0] - 2026-08-24
241
402
 
242
403
  First public release.
@@ -268,7 +429,9 @@ First public release.
268
429
  - Published to PyPI via OIDC trusted publishing; no long-lived API token is
269
430
  involved in a release.
270
431
 
271
- [Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.0...HEAD
432
+ [Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.2...HEAD
433
+ [0.4.2]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.1...v0.4.2
434
+ [0.4.1]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.0...v0.4.1
272
435
  [0.4.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.3.0...v0.4.0
273
436
  [0.3.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.2.1...v0.3.0
274
437
  [0.2.1]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.2.0...v0.2.1
@@ -1,7 +1,7 @@
1
1
  cff-version: 1.2.0
2
2
  message: "If you use this software, please cite it using the following metadata."
3
3
  title: "DataExcept"
4
- version: "0.4.0"
4
+ version: "0.4.2"
5
5
  authors:
6
6
  - family-names: "Ribeiro"
7
7
  given-names: "Diogo"
@@ -11,4 +11,4 @@ authors:
11
11
  type: software
12
12
  url: "https://github.com/DiogoRibeiro7/DataExcept"
13
13
  repository-code: "https://github.com/DiogoRibeiro7/DataExcept"
14
- date-released: "2026-08-24"
14
+ date-released: "2026-08-25"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DataExcept
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: A Python package providing structured, easily-extendable custom exception types.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -9,13 +9,14 @@ Author: Diogo Ribeiro
9
9
  Author-email: dfr@esmad.ipp.pt
10
10
  Maintainer: Diogo Ribeiro
11
11
  Maintainer-email: diogo.debastos.ribeiro@gmail.com
12
- Requires-Python: >=3.10,<3.14
12
+ Requires-Python: >=3.10,<3.15
13
13
  Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3 :: Only
15
15
  Classifier: Programming Language :: Python :: 3.10
16
16
  Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
19
20
  Requires-Dist: tomli ; python_version < "3.11"
20
21
  Project-URL: Changelog, https://github.com/DiogoRibeiro7/DataExcept/releases
21
22
  Project-URL: Documentation, https://diogoribeiro7.github.io/DataExcept/
@@ -41,13 +42,13 @@ Description-Content-Type: text/markdown
41
42
 
42
43
  ## 🎯 Key Features
43
44
 
44
- - **🏗️ Hierarchical Structure**: Catch one specific error, a whole domain, or everything via `DataExceptError`
45
+ - **🏗️ Hierarchical Structure**: Catch one specific error, a whole domain, or every operational error via `DataExceptError`
45
46
  - **📦 One Import**: Every exception is available from `dataexcept` directly, or from its domain module — same objects either way
46
- - **📊 Data Science Focused**: 99 exception classes covering ML pipelines, feature engineering, model training
47
- - **🔧 Production Ready**: Logging helpers, error context, and exceptions that survive a process boundary intact
47
+ - **📊 Data Science Focused**: 100 exception classes covering ML pipelines, feature engineering, model training
48
+ - **🔧 Production Ready**: Logging helpers, error context, and exceptions that pickle — so they cross a process boundary with their message, attributes and cause intact
48
49
  - **📚 Academic Quality**: Proper documentation, type hints, and citation support
49
- - **🐍 Python 3.10+**: Modern Python with full type safety
50
- - **🧪 Well Tested**: 690+ tests at 92% branch coverage of the package, gated in CI
50
+ - **🐍 Python 3.10 – 3.14**: Every supported version tested in CI, with full type safety
51
+ - **🧪 Well Tested**: Full branch coverage of the package gated in CI, with contract tests over every exception class
51
52
 
52
53
  ## 📦 Quick Installation
53
54
 
@@ -213,7 +214,7 @@ except Exception as exc:
213
214
  ### Command Line Interface
214
215
 
215
216
  ```bash
216
- # List every exception class the package exports (99 of them, alphabetically)
217
+ # List every exception class the package exports (100 of them, alphabetically)
217
218
  $ dataexcept list
218
219
  ApiError
219
220
  AuthenticationError
@@ -224,7 +225,7 @@ BiasDetectionError
224
225
 
225
226
  # Check version
226
227
  $ dataexcept --version
227
- dataexcept 0.4.0
228
+ dataexcept 0.4.2
228
229
  ```
229
230
 
230
231
  ## 🎯 Use Cases
@@ -374,7 +375,7 @@ If you use DataExcept in your research, please cite it:
374
375
  author = {Ribeiro, Diogo},
375
376
  title = {DataExcept: Structured Exception Handling for Data Science},
376
377
  url = {https://github.com/DiogoRibeiro7/DataExcept},
377
- version = {0.4.0},
378
+ version = {0.4.2},
378
379
  year = {2026},
379
380
  publisher = {GitHub}
380
381
  }
@@ -15,13 +15,13 @@
15
15
 
16
16
  ## 🎯 Key Features
17
17
 
18
- - **🏗️ Hierarchical Structure**: Catch one specific error, a whole domain, or everything via `DataExceptError`
18
+ - **🏗️ Hierarchical Structure**: Catch one specific error, a whole domain, or every operational error via `DataExceptError`
19
19
  - **📦 One Import**: Every exception is available from `dataexcept` directly, or from its domain module — same objects either way
20
- - **📊 Data Science Focused**: 99 exception classes covering ML pipelines, feature engineering, model training
21
- - **🔧 Production Ready**: Logging helpers, error context, and exceptions that survive a process boundary intact
20
+ - **📊 Data Science Focused**: 100 exception classes covering ML pipelines, feature engineering, model training
21
+ - **🔧 Production Ready**: Logging helpers, error context, and exceptions that pickle — so they cross a process boundary with their message, attributes and cause intact
22
22
  - **📚 Academic Quality**: Proper documentation, type hints, and citation support
23
- - **🐍 Python 3.10+**: Modern Python with full type safety
24
- - **🧪 Well Tested**: 690+ tests at 92% branch coverage of the package, gated in CI
23
+ - **🐍 Python 3.10 – 3.14**: Every supported version tested in CI, with full type safety
24
+ - **🧪 Well Tested**: Full branch coverage of the package gated in CI, with contract tests over every exception class
25
25
 
26
26
  ## 📦 Quick Installation
27
27
 
@@ -187,7 +187,7 @@ except Exception as exc:
187
187
  ### Command Line Interface
188
188
 
189
189
  ```bash
190
- # List every exception class the package exports (99 of them, alphabetically)
190
+ # List every exception class the package exports (100 of them, alphabetically)
191
191
  $ dataexcept list
192
192
  ApiError
193
193
  AuthenticationError
@@ -198,7 +198,7 @@ BiasDetectionError
198
198
 
199
199
  # Check version
200
200
  $ dataexcept --version
201
- dataexcept 0.4.0
201
+ dataexcept 0.4.2
202
202
  ```
203
203
 
204
204
  ## 🎯 Use Cases
@@ -348,7 +348,7 @@ If you use DataExcept in your research, please cite it:
348
348
  author = {Ribeiro, Diogo},
349
349
  title = {DataExcept: Structured Exception Handling for Data Science},
350
350
  url = {https://github.com/DiogoRibeiro7/DataExcept},
351
- version = {0.4.0},
351
+ version = {0.4.2},
352
352
  year = {2026},
353
353
  publisher = {GitHub}
354
354
  }
@@ -38,7 +38,7 @@ from . import ( # noqa: F401
38
38
  security_exceptions,
39
39
  )
40
40
  from ._deprecation import resolve_deprecated
41
- from .base import DataExceptError
41
+ from .base import DataExceptError, UnpicklableCause, UnpicklableValue
42
42
  from .database_exceptions import (
43
43
  DatabaseConnectionError,
44
44
  DatabaseError,
@@ -163,8 +163,12 @@ from .security_exceptions import (
163
163
  )
164
164
 
165
165
  __all__ = [
166
- # The root of the hierarchy: catches anything this package raises.
166
+ # The root of the hierarchy: catches every operational exception the
167
+ # package raises.
167
168
  "DataExceptError",
169
+ # Placeholders for state that could not survive serialization.
170
+ "UnpicklableCause",
171
+ "UnpicklableValue",
168
172
  # Every exception class the package defines.
169
173
  "ApiError",
170
174
  "AuthenticationError",
@@ -15,8 +15,14 @@ def is_number(value: object) -> bool:
15
15
  science. ``numbers.Real`` accepts them because NumPy registers its scalar
16
16
  types with the ABC, and it needs no dependency on NumPy to do so.
17
17
 
18
+ Booleans are excluded: ``bool`` subclasses ``int``, so ``numbers.Real``
19
+ would accept ``True`` as a metric.
20
+
18
21
  This is a function rather than an inline ``isinstance`` because narrowing a
19
22
  value to ``numbers.Real`` defeats mypy's inference for the rest of the
20
23
  enclosing class.
21
24
  """
22
- return isinstance(value, numbers.Real)
25
+ # bool subclasses int, so numbers.Real accepts True and False. A boolean
26
+ # is never a meaningful metric, threshold or ratio, and accepting one hides
27
+ # a caller passing the wrong variable.
28
+ return not isinstance(value, bool) and isinstance(value, numbers.Real)
@@ -0,0 +1,195 @@
1
+ """The root of the DataExcept exception hierarchy.
2
+
3
+ Every operational exception this package defines derives from
4
+ :class:`DataExceptError`, so a caller can catch the whole library with one
5
+ clause while still catching narrowly where it matters::
6
+
7
+ try:
8
+ run_pipeline()
9
+ except ValidationError:
10
+ ... # exactly this failure
11
+ except DataExceptError:
12
+ ... # anything else DataExcept raised
13
+
14
+ (Constructors also raise plain ``TypeError`` when given invalid arguments.
15
+ Those are programming errors, not operational ones, and are deliberately not
16
+ part of this hierarchy.)
17
+
18
+ The base also carries the serialization contract for the hierarchy. Two
19
+ problems make that necessary:
20
+
21
+ * Most constructors take several arguments while ``Exception.args`` holds only
22
+ the rendered message, so the default protocol -- which replays ``args``
23
+ through ``__init__`` -- cannot rebuild them.
24
+ * Several exceptions accept arbitrary caller state (``DataValidationError``
25
+ takes any ``value``), and that state may not be pickleable at all.
26
+
27
+ An exception that cannot cross a process boundary is useless exactly where a
28
+ data pipeline needs it most, so rather than fail, unpickleable state is
29
+ replaced by a description of what was there.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import pickle
35
+ from typing import Any, Dict, Optional, Tuple, Type
36
+
37
+ from .redaction import redact_urls_in_text
38
+
39
+ __all__ = ["DataExceptError", "UnpicklableCause", "UnpicklableValue"]
40
+
41
+ #: Attribute names used across the package to hold the exception that caused
42
+ #: this one. Checked in order; the first that holds an exception wins.
43
+ _CAUSE_ATTRIBUTES = ("original", "original_exception", "cause")
44
+
45
+
46
+ class UnpicklableValue:
47
+ """Stands in for state that could not survive serialization.
48
+
49
+ An exception carrying a lambda, an open file or a lock would otherwise be
50
+ unraisable across a process boundary. Keeping a description preserves what
51
+ the value was for debugging, which is the reason it was attached.
52
+ """
53
+
54
+ __slots__ = ("description",)
55
+
56
+ def __init__(self, description: str) -> None:
57
+ self.description = description
58
+
59
+ def __repr__(self) -> str:
60
+ return f"<unpicklable: {self.description}>"
61
+
62
+ def __str__(self) -> str:
63
+ return self.__repr__()
64
+
65
+ def __eq__(self, other: object) -> bool:
66
+ return (
67
+ isinstance(other, UnpicklableValue)
68
+ and other.description == self.description
69
+ )
70
+
71
+ def __hash__(self) -> int:
72
+ return hash(self.description)
73
+
74
+
75
+ def _safe(value: Any) -> Any:
76
+ """Return *value*, or a placeholder if it cannot be pickled."""
77
+ try:
78
+ pickle.dumps(value)
79
+ except Exception:
80
+ try:
81
+ description = f"{type(value).__name__}: {value!r}"
82
+ except Exception: # pragma: no cover - a repr that itself raises
83
+ description = type(value).__name__
84
+ return UnpicklableValue(description[:200])
85
+ return value
86
+
87
+
88
+ def _safe_exception(exc: Optional[BaseException]) -> Optional[BaseException]:
89
+ """Return *exc*, or an exception describing it if it cannot be pickled."""
90
+ if exc is None:
91
+ return None
92
+ try:
93
+ pickle.dumps(exc)
94
+ except Exception:
95
+ return UnpicklableCause(f"{type(exc).__name__}: {exc}")
96
+ return exc
97
+
98
+
99
+ def _rebuild(
100
+ cls: Type["DataExceptError"],
101
+ args: Tuple[Any, ...],
102
+ state: Dict[str, Any],
103
+ cause: Optional[BaseException] = None,
104
+ context: Optional[BaseException] = None,
105
+ suppress_context: bool = False,
106
+ ) -> "DataExceptError":
107
+ """Recreate *cls* without replaying its ``__init__``.
108
+
109
+ Constructors validate and render a message from their arguments; replaying
110
+ them would need those arguments, which ``args`` does not carry. Restoring
111
+ ``args`` and ``__dict__`` directly reproduces the exception exactly.
112
+
113
+ The three chain arguments carry defaults so that a payload pickled by an
114
+ earlier version -- which passed only ``cls``, ``args`` and ``state`` --
115
+ still loads. An exception can outlive an upgrade: it may sit in a task
116
+ queue, or be sent by a worker running the previous release.
117
+
118
+ ``__cause__``, ``__context__`` and ``__suppress_context__`` live outside
119
+ ``__dict__`` -- they are special exception state -- so they are restored
120
+ explicitly. Without this the chain is silently lost, and a traceback
121
+ rebuilt in another process no longer shows what actually failed.
122
+ """
123
+ exc = cls.__new__(cls)
124
+ Exception.__init__(exc, *args)
125
+ exc.__dict__.update(state)
126
+ exc.__cause__ = cause
127
+ exc.__context__ = context
128
+ exc.__suppress_context__ = suppress_context
129
+ return exc
130
+
131
+
132
+ class DataExceptError(Exception):
133
+ """Base class for every operational exception DataExcept raises."""
134
+
135
+ #: Passed to redact_urls_in_text when scrubbing this class's message.
136
+ #: WebhookError sets it False, because a webhook URL's path *is* the
137
+ #: credential.
138
+ _keep_url_path = True
139
+
140
+ def __init__(self, *args: Any) -> None:
141
+ # One boundary for the whole hierarchy. Whatever built the message -- a
142
+ # constructor, a caller-supplied `message`, or the text of a wrapped
143
+ # exception quoting the original URL -- it is scrubbed here, because
144
+ # redacting only the structured argument leaves all three routes open.
145
+ keep_path = type(self)._keep_url_path
146
+ if args and isinstance(args[0], str):
147
+ args = (redact_urls_in_text(args[0], keep_path=keep_path),) + args[1:]
148
+
149
+ # Many classes store the message on self.message and render *that* in
150
+ # __str__, and 18 interpolate some other attribute -- a field, a
151
+ # column, a resource -- any of which a caller can fill with a URL. So
152
+ # every stored string is swept, not just the message.
153
+ #
154
+ # redact_urls_in_text rather than redact_if_url: a message has the URL
155
+ # embedded in prose, and redact_if_url only handles a value that is
156
+ # wholly a URL. It is a no-op on anything without "://" in it, so
157
+ # ordinary names and file paths are untouched.
158
+ for name, value in list(self.__dict__.items()):
159
+ if isinstance(value, str) and "://" in value:
160
+ self.__dict__[name] = redact_urls_in_text(value, keep_path=keep_path)
161
+
162
+ super().__init__(*args)
163
+ # Constructors that wrap another exception record it on an attribute.
164
+ # Mirroring it into __cause__ is what makes a traceback print the
165
+ # underlying failure, exactly as `raise ... from exc` would; assigning
166
+ # __cause__ also sets __suppress_context__, as `raise from` does.
167
+ for attribute in _CAUSE_ATTRIBUTES:
168
+ candidate = getattr(self, attribute, None)
169
+ if isinstance(candidate, BaseException):
170
+ self.__cause__ = candidate
171
+ break
172
+
173
+ def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]:
174
+ args = tuple(_safe(arg) for arg in self.args)
175
+ state = {key: _safe(value) for key, value in self.__dict__.items()}
176
+ return (
177
+ _rebuild,
178
+ (
179
+ type(self),
180
+ args,
181
+ state,
182
+ _safe_exception(self.__cause__),
183
+ _safe_exception(self.__context__),
184
+ self.__suppress_context__,
185
+ ),
186
+ )
187
+
188
+
189
+ class UnpicklableCause(DataExceptError):
190
+ """Stands in for a cause that could not be serialized.
191
+
192
+ ``__cause__`` and ``__context__`` must be exceptions, so the placeholder
193
+ used for ordinary attributes will not do here. Dropping the chain instead
194
+ would silently lose the reason for the failure.
195
+ """
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from typing import Optional
6
6
 
7
7
  from .base import DataExceptError
8
+ from .redaction import redact_if_url
8
9
 
9
10
 
10
11
  class DataEngineeringError(DataExceptError):
@@ -111,7 +112,7 @@ class MissingPartitionError(DataEngineeringError):
111
112
  message: Optional custom error message.
112
113
  """
113
114
  self.partition = partition
114
- self.location = location
115
+ self.location = redact_if_url(location)
115
116
  default = f"Partition '{partition}' not found at {location}"
116
117
  super().__init__(message or default)
117
118
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from typing import Any, Optional, Sequence
6
6
 
7
7
  from .._validation import is_number
8
+ from ..redaction import redact_if_url
8
9
  from .base import DataScienceError
9
10
 
10
11
 
@@ -26,7 +27,7 @@ class DataLoadingError(DataScienceError):
26
27
  )
27
28
 
28
29
  message = f"Failed to load data from {source!r}: {original}"
29
- self.source = source
30
+ self.source = redact_if_url(source)
30
31
  self.original = original
31
32
  super().__init__(message)
32
33
 
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from typing import Any, Optional
6
6
 
7
7
  from .._validation import is_number
8
+ from ..redaction import redact_if_url
8
9
  from .base import DataScienceError
9
10
 
10
11
 
@@ -26,7 +27,7 @@ class ModelSerializationError(DataScienceError):
26
27
  )
27
28
 
28
29
  message = f"Failed to serialize to {path!r}: {original}"
29
- self.path = path
30
+ self.path = redact_if_url(path)
30
31
  self.original = original
31
32
  super().__init__(message)
32
33
 
@@ -69,8 +69,10 @@ class ConvergenceError(ModelTrainingError):
69
69
  f"Model '{model_type}' failed to converge after "
70
70
  f"{iterations} iterations"
71
71
  )
72
- super().__init__(model_type=model_type, epoch=None, message=message)
72
+ # Assigned before super(): DataExceptError.__init__ sweeps the stored
73
+ # strings for URLs, and anything set afterwards escapes that.
73
74
  self.iterations = iterations
75
+ super().__init__(model_type=model_type, epoch=None, message=message)
74
76
 
75
77
  def __str__(self) -> str:
76
78
  return f"[ConvergenceError] {self.message}"
@@ -41,9 +41,14 @@ class EmailError(NotificationError):
41
41
  class WebhookError(NotificationError):
42
42
  """Raised when a webhook POST fails."""
43
43
 
44
+ # Slack, Discord and others put the secret in the webhook path, so keeping
45
+ # the path would defeat the redaction. This also applies to the scrubbing
46
+ # of the whole message, which is how a wrapped HTTP exception quoting the
47
+ # original URL gets its path dropped too.
48
+ _keep_url_path = False
49
+
44
50
  def __init__(self, url: str, original_exception: Exception | None = None):
45
- # Webhook URLs commonly authenticate through a query parameter.
46
- self.url = redact_url(url)
51
+ self.url = redact_url(url, keep_path=False)
47
52
  # original_exception is set by NotificationError.__init__ below.
48
53
  msg = f"Webhook to URL '{self.url}' failed"
49
54
  if original_exception:
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from .base import DataExceptError
6
+ from .redaction import redact_if_url
6
7
 
7
8
 
8
9
  class CustomIOError(DataExceptError):
@@ -21,7 +22,7 @@ class FileReadError(CustomIOError):
21
22
  path: File path that could not be read.
22
23
  original: Optional underlying exception.
23
24
  """
24
- self.path = path
25
+ self.path = redact_if_url(path)
25
26
  self.original = original
26
27
  msg = f"Failed to read file '{path}'"
27
28
  if original:
@@ -39,7 +40,7 @@ class FileWriteError(CustomIOError):
39
40
  path: File path that could not be written to.
40
41
  original: Optional underlying exception.
41
42
  """
42
- self.path = path
43
+ self.path = redact_if_url(path)
43
44
  self.original = original
44
45
  msg = f"Failed to write file '{path}'"
45
46
  if original:
@@ -56,7 +57,7 @@ class FileLockError(CustomIOError):
56
57
  Args:
57
58
  path: Path of the lock file.
58
59
  """
59
- self.path = path
60
+ self.path = redact_if_url(path)
60
61
  super().__init__(f"Unable to obtain lock for '{path}'")
61
62
 
62
63