DataExcept 1.2.0__tar.gz → 1.4.0__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.
- {dataexcept-1.2.0 → dataexcept-1.4.0}/CHANGELOG.md +67 -2
- {dataexcept-1.2.0 → dataexcept-1.4.0}/CITATION.cff +2 -2
- {dataexcept-1.2.0 → dataexcept-1.4.0}/PKG-INFO +6 -16
- {dataexcept-1.2.0 → dataexcept-1.4.0}/README.md +5 -15
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/__init__.py +3 -8
- dataexcept-1.4.0/dataexcept/_causes.py +31 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/base.py +31 -45
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/database_exceptions.py +19 -5
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/exceptions/authentication.py +11 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/exceptions/external.py +24 -5
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/exceptions/validation.py +6 -0
- dataexcept-1.4.0/dataexcept/failure_metadata.py +45 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/pipeline_exceptions.py +15 -3
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/serialization.py +79 -16
- dataexcept-1.4.0/dataexcept/wrapping.py +111 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/pyproject.toml +1 -1
- dataexcept-1.2.0/dataexcept/wrapping.py +0 -103
- {dataexcept-1.2.0 → dataexcept-1.4.0}/LICENSE +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/__main__.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/_validation.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/dataengineering_exceptions.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/__init__.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/base.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/ingestion.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/operations.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/training.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/exceptions/__init__.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/exceptions/base.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/exceptions/configuration.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/exceptions/lifecycle.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/exceptions/notification.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/exceptions/parsing.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/exceptions/scheduling.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/io_exceptions.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/logging_helpers.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/network_exceptions.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/pandas_exceptions.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/py.typed +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/redaction.py +0 -0
- {dataexcept-1.2.0 → dataexcept-1.4.0}/dataexcept/security_exceptions.py +0 -0
|
@@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.4.0] - 2026-09-04
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **A canonical cause contract for operational exceptions.** New code can use
|
|
15
|
+
keyword-only `cause=` and the public `.cause` attribute, while existing
|
|
16
|
+
`original=` and `original_exception=` APIs remain supported where they were
|
|
17
|
+
already public. Accepted causes populate `__cause__`, survive pickling and
|
|
18
|
+
structured serialization, and invalid or ambiguous aliases fail fast.
|
|
19
|
+
- **Machine-readable failure metadata.** Every `DataExceptError` exposes
|
|
20
|
+
`failure_kind`, `retryable`, `retry_after_seconds` and the immutable
|
|
21
|
+
`FailureMetadata` value object. Validation and authentication/authorization
|
|
22
|
+
failures have conservative permanent/non-retryable defaults; generic
|
|
23
|
+
infrastructure failures remain unknown unless backend evidence says more.
|
|
24
|
+
- `wrap()` and `wrapping()` accept `failure_metadata=` so integrations can
|
|
25
|
+
attach backend-informed retryability without parsing messages or changing
|
|
26
|
+
exception constructors.
|
|
27
|
+
- Structured envelopes now include a stable `failure` object carrying failure
|
|
28
|
+
kind, retryability and optional retry delay.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- `wrap()` now prefers the canonical `cause` keyword when a target supports it,
|
|
33
|
+
while preserving explicit legacy cause overrides and always chaining the
|
|
34
|
+
actual wrapped exception through `__cause__`.
|
|
35
|
+
- Release automation now uses one permanent privileged Release workflow. Normal
|
|
36
|
+
release preparation happens through ordinary branches and pull requests;
|
|
37
|
+
publication is requested only after the reviewed release commit reaches
|
|
38
|
+
protected `main`.
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- Failure-metadata serialization now preserves the serializer's never-throw
|
|
43
|
+
contract even for hostile or malformed custom accessors. Invalid kinds,
|
|
44
|
+
retryability values, string or boolean delays, negative delays and non-finite
|
|
45
|
+
delays all fall back to the canonical unknown failure record; valid numeric
|
|
46
|
+
delays are normalised to `float`.
|
|
47
|
+
- Cause-aware wrapping no longer injects a second canonical cause when a caller
|
|
48
|
+
already supplied a legacy `original` or `original_exception` override.
|
|
49
|
+
|
|
50
|
+
## [1.3.0] - 2026-09-03
|
|
51
|
+
|
|
52
|
+
### Added
|
|
53
|
+
|
|
54
|
+
- **Structured `ExceptionGroup` support** on Python 3.11+:
|
|
55
|
+
`exception_to_dict` and `exception_to_json` now preserve group members under
|
|
56
|
+
an `exceptions` field, including nested groups, instead of flattening
|
|
57
|
+
concurrent failures into one rendered message.
|
|
58
|
+
- Group members use the same public-attribute, redaction, cycle and shared
|
|
59
|
+
`max_depth` guarantees as ordinary causes and contexts. Python 3.10 remains
|
|
60
|
+
supported without an `exceptiongroup` backport or additional runtime
|
|
61
|
+
dependency.
|
|
62
|
+
|
|
63
|
+
### Changed
|
|
64
|
+
|
|
65
|
+
- Hostile or malformed exception-group member metadata now degrades to the
|
|
66
|
+
ordinary exception record rather than allowing serialization itself to fail.
|
|
67
|
+
- The README now uses a PyPI-backed version badge and avoids hard-coded version
|
|
68
|
+
and citation values that can drift between releases. The documented exception
|
|
69
|
+
count is contract-tested against the package.
|
|
70
|
+
- The roadmap now records a future language-neutral envelope schema and optional
|
|
71
|
+
Pino interoperability track without claiming Node.js/Pino support today.
|
|
72
|
+
|
|
10
73
|
## [1.2.0] - 2026-09-02
|
|
11
74
|
|
|
12
75
|
### Added
|
|
@@ -165,7 +228,7 @@ and the [migration guide](https://diogoribeiro7.github.io/DataExcept/migration/)
|
|
|
165
228
|
- Two classes assigned their attributes *after* calling `super().__init__`, so
|
|
166
229
|
the sweep never saw them. Both now assign first, and a test fails if any
|
|
167
230
|
constructor does it again.
|
|
168
|
-
- The URL pattern carried a
|
|
231
|
+
- The URL pattern carried a `\b` anchor, so a URL directly following a word
|
|
169
232
|
character was never matched — including `feature_https://...`, the step name
|
|
170
233
|
`FeaturePreprocessingError` builds from its own argument.
|
|
171
234
|
|
|
@@ -547,7 +610,9 @@ First public release.
|
|
|
547
610
|
- Published to PyPI via OIDC trusted publishing; no long-lived API token is
|
|
548
611
|
involved in a release.
|
|
549
612
|
|
|
550
|
-
[Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.
|
|
613
|
+
[Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.4.0...HEAD
|
|
614
|
+
[1.4.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.3.0...v1.4.0
|
|
615
|
+
[1.3.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.2.0...v1.3.0
|
|
551
616
|
[1.2.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.1.0...v1.2.0
|
|
552
617
|
[1.1.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.0.0...v1.1.0
|
|
553
618
|
[1.0.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.3...v1.0.0
|
|
@@ -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: "1.
|
|
4
|
+
version: "1.4.0"
|
|
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-09-
|
|
14
|
+
date-released: "2026-09-04"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: DataExcept
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.0
|
|
4
4
|
Summary: A Python package providing structured, easily-extendable custom exception types.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -27,7 +27,7 @@ Description-Content-Type: text/markdown
|
|
|
27
27
|
|
|
28
28
|
# DataExcept
|
|
29
29
|
|
|
30
|
-
[](https://github.com/DiogoRibeiro7/DataExcept/actions/workflows/ci.yml) [](https://github.com/DiogoRibeiro7/DataExcept/actions/workflows/ci.yml) [](https://pypi.org/project/DataExcept/) [](https://pypi.org/project/DataExcept/) [](https://diogoribeiro7.github.io/DataExcept/htmlcov/) [](https://diogoribeiro7.github.io/DataExcept/) [](https://opensource.org/licenses/MIT) [](https://github.com/psf/black) [](https://mypy-lang.org/)
|
|
31
31
|
|
|
32
32
|
**DataExcept** is a production-ready Python library that provides **structured, hierarchical exception classes** specifically designed for **data science**, **machine learning**, and **data engineering** workflows. Stop debugging generic `ValueError`s and `RuntimeError`s -- get meaningful, actionable error messages that help you understand exactly what went wrong in your data pipeline.
|
|
33
33
|
|
|
@@ -223,9 +223,9 @@ BatchProcessingError
|
|
|
223
223
|
BiasDetectionError
|
|
224
224
|
...
|
|
225
225
|
|
|
226
|
-
# Check version
|
|
226
|
+
# Check the installed version
|
|
227
227
|
$ dataexcept --version
|
|
228
|
-
dataexcept
|
|
228
|
+
dataexcept <installed version>
|
|
229
229
|
```
|
|
230
230
|
|
|
231
231
|
## 🎯 Use Cases
|
|
@@ -369,18 +369,8 @@ through [SECURITY.md](SECURITY.md), not the public issue tracker.
|
|
|
369
369
|
|
|
370
370
|
## 🎓 Citation
|
|
371
371
|
|
|
372
|
-
If you use DataExcept in your research, please cite
|
|
373
|
-
|
|
374
|
-
```bibtex
|
|
375
|
-
@software{ribeiro_dataexcept_2026,
|
|
376
|
-
author = {Ribeiro, Diogo},
|
|
377
|
-
title = {DataExcept: Structured Exception Handling for Data Science},
|
|
378
|
-
url = {https://github.com/DiogoRibeiro7/DataExcept},
|
|
379
|
-
version = {1.0.0},
|
|
380
|
-
year = {2026},
|
|
381
|
-
publisher = {GitHub}
|
|
382
|
-
}
|
|
383
|
-
```
|
|
372
|
+
If you use DataExcept in your research, please cite the exact release you used.
|
|
373
|
+
The canonical release metadata is maintained in [CITATION.cff](CITATION.cff).
|
|
384
374
|
|
|
385
375
|
## 📄 License
|
|
386
376
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# DataExcept
|
|
2
2
|
|
|
3
|
-
[](https://github.com/DiogoRibeiro7/DataExcept/actions/workflows/ci.yml) [](https://github.com/DiogoRibeiro7/DataExcept/actions/workflows/ci.yml) [](https://pypi.org/project/DataExcept/) [](https://pypi.org/project/DataExcept/) [](https://diogoribeiro7.github.io/DataExcept/htmlcov/) [](https://diogoribeiro7.github.io/DataExcept/) [](https://opensource.org/licenses/MIT) [](https://github.com/psf/black) [](https://mypy-lang.org/)
|
|
4
4
|
|
|
5
5
|
**DataExcept** is a production-ready Python library that provides **structured, hierarchical exception classes** specifically designed for **data science**, **machine learning**, and **data engineering** workflows. Stop debugging generic `ValueError`s and `RuntimeError`s -- get meaningful, actionable error messages that help you understand exactly what went wrong in your data pipeline.
|
|
6
6
|
|
|
@@ -196,9 +196,9 @@ BatchProcessingError
|
|
|
196
196
|
BiasDetectionError
|
|
197
197
|
...
|
|
198
198
|
|
|
199
|
-
# Check version
|
|
199
|
+
# Check the installed version
|
|
200
200
|
$ dataexcept --version
|
|
201
|
-
dataexcept
|
|
201
|
+
dataexcept <installed version>
|
|
202
202
|
```
|
|
203
203
|
|
|
204
204
|
## 🎯 Use Cases
|
|
@@ -342,18 +342,8 @@ through [SECURITY.md](SECURITY.md), not the public issue tracker.
|
|
|
342
342
|
|
|
343
343
|
## 🎓 Citation
|
|
344
344
|
|
|
345
|
-
If you use DataExcept in your research, please cite
|
|
346
|
-
|
|
347
|
-
```bibtex
|
|
348
|
-
@software{ribeiro_dataexcept_2026,
|
|
349
|
-
author = {Ribeiro, Diogo},
|
|
350
|
-
title = {DataExcept: Structured Exception Handling for Data Science},
|
|
351
|
-
url = {https://github.com/DiogoRibeiro7/DataExcept},
|
|
352
|
-
version = {1.0.0},
|
|
353
|
-
year = {2026},
|
|
354
|
-
publisher = {GitHub}
|
|
355
|
-
}
|
|
356
|
-
```
|
|
345
|
+
If you use DataExcept in your research, please cite the exact release you used.
|
|
346
|
+
The canonical release metadata is maintained in [CITATION.cff](CITATION.cff).
|
|
357
347
|
|
|
358
348
|
## 📄 License
|
|
359
349
|
|
|
@@ -111,6 +111,7 @@ from .exceptions import (
|
|
|
111
111
|
ValidationError,
|
|
112
112
|
WebhookError,
|
|
113
113
|
)
|
|
114
|
+
from .failure_metadata import FailureKind, FailureMetadata
|
|
114
115
|
from .io_exceptions import (
|
|
115
116
|
CustomIOError,
|
|
116
117
|
FileLockError,
|
|
@@ -163,13 +164,11 @@ from .serialization import exception_to_dict, exception_to_json
|
|
|
163
164
|
from .wrapping import wrap, wrapping
|
|
164
165
|
|
|
165
166
|
__all__ = [
|
|
166
|
-
# The root of the hierarchy: catches every operational exception the
|
|
167
|
-
# package raises.
|
|
168
167
|
"DataExceptError",
|
|
169
|
-
# Placeholders for state that could not survive serialization.
|
|
170
168
|
"UnpicklableCause",
|
|
171
169
|
"UnpicklableValue",
|
|
172
|
-
|
|
170
|
+
"FailureKind",
|
|
171
|
+
"FailureMetadata",
|
|
173
172
|
"ApiError",
|
|
174
173
|
"AuthenticationError",
|
|
175
174
|
"AuthorizationError",
|
|
@@ -268,18 +267,14 @@ __all__ = [
|
|
|
268
267
|
"UnderfittingError",
|
|
269
268
|
"ValidationError",
|
|
270
269
|
"WebhookError",
|
|
271
|
-
# Turning a third-party exception into one of these.
|
|
272
270
|
"wrap",
|
|
273
271
|
"wrapping",
|
|
274
|
-
# Structured serialization for APIs, queues and telemetry.
|
|
275
272
|
"exception_to_dict",
|
|
276
273
|
"exception_to_json",
|
|
277
|
-
# Logging helpers.
|
|
278
274
|
"Context",
|
|
279
275
|
"log_and_raise",
|
|
280
276
|
"log_exception",
|
|
281
277
|
"log_then_raise",
|
|
282
|
-
# Domain modules, for callers who prefer a qualified import.
|
|
283
278
|
"database_exceptions",
|
|
284
279
|
"dataengineering_exceptions",
|
|
285
280
|
"datascience_exceptions",
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Internal helpers for the canonical wrapped-exception contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["resolve_cause"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def resolve_cause(
|
|
9
|
+
*,
|
|
10
|
+
cause: Exception | None = None,
|
|
11
|
+
original: Exception | None = None,
|
|
12
|
+
original_exception: Exception | None = None,
|
|
13
|
+
) -> Exception | None:
|
|
14
|
+
"""Return the one supplied cause, rejecting invalid or ambiguous aliases.
|
|
15
|
+
|
|
16
|
+
``cause`` is the canonical public keyword. ``original`` and
|
|
17
|
+
``original_exception`` remain supported only for backward compatibility.
|
|
18
|
+
"""
|
|
19
|
+
values = (
|
|
20
|
+
("cause", cause),
|
|
21
|
+
("original", original),
|
|
22
|
+
("original_exception", original_exception),
|
|
23
|
+
)
|
|
24
|
+
for name, value in values:
|
|
25
|
+
if value is not None and not isinstance(value, Exception):
|
|
26
|
+
raise TypeError(f"{name} must be Exception or None")
|
|
27
|
+
|
|
28
|
+
supplied = [value for _, value in values if value is not None]
|
|
29
|
+
if len(supplied) > 1:
|
|
30
|
+
raise TypeError("provide only one of cause, original, or original_exception")
|
|
31
|
+
return supplied[0] if supplied else None
|
|
@@ -34,22 +34,16 @@ from __future__ import annotations
|
|
|
34
34
|
import pickle
|
|
35
35
|
from typing import Any, Dict, Optional, Tuple, Type
|
|
36
36
|
|
|
37
|
+
from .failure_metadata import FailureKind, FailureMetadata
|
|
37
38
|
from .redaction import redact_urls_in_text
|
|
38
39
|
|
|
39
40
|
__all__ = ["DataExceptError", "UnpicklableCause", "UnpicklableValue"]
|
|
40
41
|
|
|
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
42
|
_CAUSE_ATTRIBUTES = ("original", "original_exception", "cause")
|
|
44
43
|
|
|
45
44
|
|
|
46
45
|
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
|
-
"""
|
|
46
|
+
"""Stands in for state that could not survive serialization."""
|
|
53
47
|
|
|
54
48
|
__slots__ = ("description",)
|
|
55
49
|
|
|
@@ -79,7 +73,7 @@ def _safe(value: Any) -> Any:
|
|
|
79
73
|
except Exception:
|
|
80
74
|
try:
|
|
81
75
|
description = f"{type(value).__name__}: {value!r}"
|
|
82
|
-
except Exception: # pragma: no cover
|
|
76
|
+
except Exception: # pragma: no cover
|
|
83
77
|
description = type(value).__name__
|
|
84
78
|
return UnpicklableValue(description[:200])
|
|
85
79
|
return value
|
|
@@ -104,22 +98,7 @@ def _rebuild(
|
|
|
104
98
|
context: Optional[BaseException] = None,
|
|
105
99
|
suppress_context: bool = False,
|
|
106
100
|
) -> "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
|
-
"""
|
|
101
|
+
"""Recreate *cls* without replaying its ``__init__``."""
|
|
123
102
|
exc = cls.__new__(cls)
|
|
124
103
|
Exception.__init__(exc, *args)
|
|
125
104
|
exc.__dict__.update(state)
|
|
@@ -132,44 +111,51 @@ def _rebuild(
|
|
|
132
111
|
class DataExceptError(Exception):
|
|
133
112
|
"""Base class for every operational exception DataExcept raises."""
|
|
134
113
|
|
|
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
114
|
_keep_url_path = True
|
|
115
|
+
_default_failure_metadata = FailureMetadata()
|
|
139
116
|
|
|
140
117
|
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
118
|
keep_path = type(self)._keep_url_path
|
|
146
119
|
if args and isinstance(args[0], str):
|
|
147
120
|
args = (redact_urls_in_text(args[0], keep_path=keep_path),) + args[1:]
|
|
148
121
|
|
|
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
122
|
for name, value in list(self.__dict__.items()):
|
|
159
123
|
if isinstance(value, str) and "://" in value:
|
|
160
124
|
self.__dict__[name] = redact_urls_in_text(value, keep_path=keep_path)
|
|
161
125
|
|
|
162
126
|
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
127
|
for attribute in _CAUSE_ATTRIBUTES:
|
|
168
128
|
candidate = getattr(self, attribute, None)
|
|
169
129
|
if isinstance(candidate, BaseException):
|
|
170
130
|
self.__cause__ = candidate
|
|
171
131
|
break
|
|
172
132
|
|
|
133
|
+
@property
|
|
134
|
+
def failure_metadata(self) -> FailureMetadata:
|
|
135
|
+
override = self.__dict__.get("_failure_metadata_override")
|
|
136
|
+
if isinstance(override, FailureMetadata):
|
|
137
|
+
return override
|
|
138
|
+
return type(self)._default_failure_metadata
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def failure_kind(self) -> FailureKind:
|
|
142
|
+
return self.failure_metadata.failure_kind
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def retryable(self) -> bool | None:
|
|
146
|
+
return self.failure_metadata.retryable
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def retry_after_seconds(self) -> float | None:
|
|
150
|
+
return self.failure_metadata.retry_after_seconds
|
|
151
|
+
|
|
152
|
+
def with_failure_metadata(self, metadata: FailureMetadata) -> "DataExceptError":
|
|
153
|
+
"""Attach backend-informed metadata and return ``self`` for chaining."""
|
|
154
|
+
if not isinstance(metadata, FailureMetadata):
|
|
155
|
+
raise TypeError("metadata must be a FailureMetadata instance")
|
|
156
|
+
self._failure_metadata_override = metadata
|
|
157
|
+
return self
|
|
158
|
+
|
|
173
159
|
def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]:
|
|
174
160
|
args = tuple(_safe(arg) for arg in self.args)
|
|
175
161
|
state = {key: _safe(value) for key, value in self.__dict__.items()}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from ._causes import resolve_cause
|
|
5
6
|
from .base import DataExceptError
|
|
6
7
|
from .redaction import redact_url
|
|
7
8
|
|
|
@@ -31,18 +32,27 @@ class DatabaseConnectionError(DatabaseError):
|
|
|
31
32
|
class QueryExecutionError(DatabaseError):
|
|
32
33
|
"""Raised when a database query execution fails."""
|
|
33
34
|
|
|
34
|
-
def __init__(
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
query: str,
|
|
38
|
+
original: Exception | None = None,
|
|
39
|
+
*,
|
|
40
|
+
cause: Exception | None = None,
|
|
41
|
+
) -> None:
|
|
35
42
|
"""Initialize QueryExecutionError.
|
|
36
43
|
|
|
37
44
|
Args:
|
|
38
45
|
query: SQL query string.
|
|
39
|
-
original:
|
|
46
|
+
original: Legacy alias for ``cause``.
|
|
47
|
+
cause: Optional underlying exception.
|
|
40
48
|
"""
|
|
49
|
+
resolved = resolve_cause(cause=cause, original=original)
|
|
41
50
|
self.query = query
|
|
42
|
-
self.original =
|
|
51
|
+
self.original = resolved
|
|
52
|
+
self.cause = resolved
|
|
43
53
|
msg = f"Query failed: {query}"
|
|
44
|
-
if
|
|
45
|
-
msg += f" ({
|
|
54
|
+
if resolved:
|
|
55
|
+
msg += f" ({resolved})"
|
|
46
56
|
super().__init__(msg)
|
|
47
57
|
|
|
48
58
|
|
|
@@ -53,14 +63,18 @@ class TransactionError(DatabaseError):
|
|
|
53
63
|
self,
|
|
54
64
|
transaction_id: str | None = None,
|
|
55
65
|
message: str | None = None,
|
|
66
|
+
*,
|
|
67
|
+
cause: Exception | None = None,
|
|
56
68
|
) -> None:
|
|
57
69
|
"""Initialize TransactionError.
|
|
58
70
|
|
|
59
71
|
Args:
|
|
60
72
|
transaction_id: Identifier for the transaction.
|
|
61
73
|
message: Optional custom error message.
|
|
74
|
+
cause: Optional underlying exception.
|
|
62
75
|
"""
|
|
63
76
|
self.transaction_id = transaction_id
|
|
77
|
+
self.cause = cause
|
|
64
78
|
default = "Database transaction failed"
|
|
65
79
|
if transaction_id:
|
|
66
80
|
default += f" (id={transaction_id})"
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
# authentication.py
|
|
2
|
+
from ..failure_metadata import FailureMetadata
|
|
2
3
|
from .base import JobError
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
class AuthenticationError(JobError):
|
|
6
7
|
"""Raised when user authentication fails."""
|
|
7
8
|
|
|
9
|
+
_default_failure_metadata = FailureMetadata(
|
|
10
|
+
failure_kind="permanent",
|
|
11
|
+
retryable=False,
|
|
12
|
+
)
|
|
13
|
+
|
|
8
14
|
def __init__(self, user: str, message: str | None = None):
|
|
9
15
|
self.user = user
|
|
10
16
|
self.message = message or f"Authentication failed for user '{user}'"
|
|
@@ -14,6 +20,11 @@ class AuthenticationError(JobError):
|
|
|
14
20
|
class AuthorizationError(JobError):
|
|
15
21
|
"""Raised when user lacks permission for an action."""
|
|
16
22
|
|
|
23
|
+
_default_failure_metadata = FailureMetadata(
|
|
24
|
+
failure_kind="permanent",
|
|
25
|
+
retryable=False,
|
|
26
|
+
)
|
|
27
|
+
|
|
17
28
|
def __init__(self, user: str, permission: str):
|
|
18
29
|
self.user = user
|
|
19
30
|
self.permission = permission
|
|
@@ -1,24 +1,43 @@
|
|
|
1
|
+
from .._causes import resolve_cause
|
|
1
2
|
from .base import JobError
|
|
2
3
|
|
|
3
4
|
|
|
4
5
|
class ServiceConnectionError(JobError):
|
|
5
6
|
"""Raised when a connection to an external service fails."""
|
|
6
7
|
|
|
7
|
-
def __init__(
|
|
8
|
+
def __init__(
|
|
9
|
+
self,
|
|
10
|
+
service_name: str,
|
|
11
|
+
original_exception: Exception | None = None,
|
|
12
|
+
*,
|
|
13
|
+
cause: Exception | None = None,
|
|
14
|
+
):
|
|
15
|
+
resolved = resolve_cause(
|
|
16
|
+
cause=cause,
|
|
17
|
+
original_exception=original_exception,
|
|
18
|
+
)
|
|
8
19
|
self.service_name = service_name
|
|
9
|
-
self.original_exception =
|
|
20
|
+
self.original_exception = resolved
|
|
21
|
+
self.cause = resolved
|
|
10
22
|
msg = f"Failed to connect to service '{service_name}'"
|
|
11
|
-
if
|
|
12
|
-
msg += f": {
|
|
23
|
+
if resolved:
|
|
24
|
+
msg += f": {resolved}"
|
|
13
25
|
super().__init__(msg)
|
|
14
26
|
|
|
15
27
|
|
|
16
28
|
class OperationTimeoutError(JobError):
|
|
17
29
|
"""Raised when an operation exceeds its time limit."""
|
|
18
30
|
|
|
19
|
-
def __init__(
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
operation: str,
|
|
34
|
+
timeout: float,
|
|
35
|
+
*,
|
|
36
|
+
cause: Exception | None = None,
|
|
37
|
+
):
|
|
20
38
|
self.operation = operation
|
|
21
39
|
self.timeout = timeout
|
|
40
|
+
self.cause = cause
|
|
22
41
|
msg = f"Operation '{operation}' timed out after {timeout} seconds"
|
|
23
42
|
super().__init__(msg)
|
|
24
43
|
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
# validation.py
|
|
2
|
+
from ..failure_metadata import FailureMetadata
|
|
2
3
|
from .base import JobError
|
|
3
4
|
|
|
4
5
|
|
|
5
6
|
class ValidationError(JobError):
|
|
6
7
|
"""Raised when input data fails validation."""
|
|
7
8
|
|
|
9
|
+
_default_failure_metadata = FailureMetadata(
|
|
10
|
+
failure_kind="permanent",
|
|
11
|
+
retryable=False,
|
|
12
|
+
)
|
|
13
|
+
|
|
8
14
|
def __init__(self, field: str, value, message: str | None = None):
|
|
9
15
|
self.field = field
|
|
10
16
|
self.value = value
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Machine-readable failure classification for DataExcept exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal, TypeAlias
|
|
8
|
+
|
|
9
|
+
__all__ = ["FailureKind", "FailureMetadata"]
|
|
10
|
+
|
|
11
|
+
FailureKind: TypeAlias = Literal["transient", "permanent", "unknown"]
|
|
12
|
+
_VALID_FAILURE_KINDS = {"transient", "permanent", "unknown"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class FailureMetadata:
|
|
17
|
+
"""Describe recovery-relevant properties of an operational failure.
|
|
18
|
+
|
|
19
|
+
``failure_kind`` describes whether the underlying condition is known to be
|
|
20
|
+
transient, permanent for the same operation/payload, or unknown.
|
|
21
|
+
``retryable`` is deliberately independent: DataExcept describes the
|
|
22
|
+
failure, while the calling application still owns retry policy.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
failure_kind: FailureKind = "unknown"
|
|
26
|
+
retryable: bool | None = None
|
|
27
|
+
retry_after_seconds: float | None = None
|
|
28
|
+
|
|
29
|
+
def __post_init__(self) -> None:
|
|
30
|
+
if self.failure_kind not in _VALID_FAILURE_KINDS:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"failure_kind must be 'transient', 'permanent', or 'unknown'"
|
|
33
|
+
)
|
|
34
|
+
if self.retryable is not None and not isinstance(self.retryable, bool):
|
|
35
|
+
raise TypeError("retryable must be bool or None")
|
|
36
|
+
if self.retry_after_seconds is None:
|
|
37
|
+
return
|
|
38
|
+
if isinstance(self.retry_after_seconds, bool) or not isinstance(
|
|
39
|
+
self.retry_after_seconds, (int, float)
|
|
40
|
+
):
|
|
41
|
+
raise TypeError("retry_after_seconds must be a number or None")
|
|
42
|
+
seconds = float(self.retry_after_seconds)
|
|
43
|
+
if not math.isfinite(seconds) or seconds < 0:
|
|
44
|
+
raise ValueError("retry_after_seconds must be finite and non-negative")
|
|
45
|
+
object.__setattr__(self, "retry_after_seconds", seconds)
|
|
@@ -4,7 +4,9 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any, Optional
|
|
6
6
|
|
|
7
|
+
from ._causes import resolve_cause
|
|
7
8
|
from .base import DataExceptError
|
|
9
|
+
from .failure_metadata import FailureMetadata
|
|
8
10
|
from .redaction import redact_if_url, redact_url
|
|
9
11
|
|
|
10
12
|
|
|
@@ -29,8 +31,6 @@ class FeaturePreprocessingError(PreprocessingError):
|
|
|
29
31
|
"""Raised when feature engineering fails."""
|
|
30
32
|
|
|
31
33
|
def __init__(self, feature: str, reason: Optional[str] = None) -> None:
|
|
32
|
-
# Assigned before super(): DataExceptError.__init__ sweeps the stored
|
|
33
|
-
# strings for URLs, and anything set afterwards escapes that.
|
|
34
34
|
self.feature = feature
|
|
35
35
|
self.reason = reason
|
|
36
36
|
super().__init__(step_name=f"feature_{feature}", details=reason)
|
|
@@ -44,10 +44,13 @@ class StorageError(PipelineError):
|
|
|
44
44
|
location: str,
|
|
45
45
|
operation: str,
|
|
46
46
|
message: Optional[str] = None,
|
|
47
|
+
*,
|
|
48
|
+
cause: Exception | None = None,
|
|
47
49
|
) -> None:
|
|
48
50
|
default = f"Storage {operation} failed at location: '{location}'."
|
|
49
51
|
self.location = redact_if_url(location)
|
|
50
52
|
self.operation = operation
|
|
53
|
+
self.cause = resolve_cause(cause=cause)
|
|
51
54
|
super().__init__(message or default)
|
|
52
55
|
|
|
53
56
|
|
|
@@ -104,6 +107,11 @@ class ExternalServiceError(PipelineError):
|
|
|
104
107
|
class ServiceAuthenticationError(ExternalServiceError):
|
|
105
108
|
"""Authentication to an external service failed."""
|
|
106
109
|
|
|
110
|
+
_default_failure_metadata = FailureMetadata(
|
|
111
|
+
failure_kind="permanent",
|
|
112
|
+
retryable=False,
|
|
113
|
+
)
|
|
114
|
+
|
|
107
115
|
def __init__(
|
|
108
116
|
self,
|
|
109
117
|
service_name: str,
|
|
@@ -116,6 +124,11 @@ class ServiceAuthenticationError(ExternalServiceError):
|
|
|
116
124
|
class ServiceAuthorizationError(ExternalServiceError):
|
|
117
125
|
"""Authorization was denied by an external service."""
|
|
118
126
|
|
|
127
|
+
_default_failure_metadata = FailureMetadata(
|
|
128
|
+
failure_kind="permanent",
|
|
129
|
+
retryable=False,
|
|
130
|
+
)
|
|
131
|
+
|
|
119
132
|
def __init__(
|
|
120
133
|
self,
|
|
121
134
|
service_name: str,
|
|
@@ -150,7 +163,6 @@ class ApiError(PipelineError):
|
|
|
150
163
|
status_code: Optional[int] = None,
|
|
151
164
|
message: Optional[str] = None,
|
|
152
165
|
) -> None:
|
|
153
|
-
# An endpoint URL may authenticate through a query parameter.
|
|
154
166
|
self.endpoint = redact_url(endpoint)
|
|
155
167
|
default = f"API call failed: {self.endpoint}"
|
|
156
168
|
if status_code is not None:
|
|
@@ -2,27 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import builtins
|
|
5
6
|
import json
|
|
6
7
|
import math
|
|
7
8
|
from collections.abc import Mapping, Sequence, Set
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
11
|
+
from .base import DataExceptError
|
|
10
12
|
from .redaction import redact_urls_in_text
|
|
11
13
|
|
|
12
14
|
__all__ = ["exception_to_dict", "exception_to_json"]
|
|
13
15
|
|
|
14
16
|
_MAX_VALUE_DEPTH = 8
|
|
15
17
|
_NOT_SCALAR = object()
|
|
18
|
+
_EXCEPTION_GROUP_TYPE = getattr(builtins, "BaseExceptionGroup", None)
|
|
19
|
+
_UNKNOWN_FAILURE = {
|
|
20
|
+
"kind": "unknown",
|
|
21
|
+
"retryable": None,
|
|
22
|
+
"retry_after_seconds": None,
|
|
23
|
+
}
|
|
16
24
|
|
|
17
25
|
|
|
18
26
|
def _redact_export_text(text: str) -> str:
|
|
19
|
-
"""Scrub URLs for export, including their paths.
|
|
20
|
-
|
|
21
|
-
Normal DataExcept messages preserve URL paths because paths are commonly
|
|
22
|
-
useful debugging context. Structured envelopes have a stricter boundary:
|
|
23
|
-
third-party errors and arbitrary caller state may put credentials in the
|
|
24
|
-
path itself, so exported text never preserves URL paths.
|
|
25
|
-
"""
|
|
27
|
+
"""Scrub URLs for export, including their paths."""
|
|
26
28
|
return redact_urls_in_text(text, keep_path=False)
|
|
27
29
|
|
|
28
30
|
|
|
@@ -112,12 +114,64 @@ def _attributes(exc: BaseException) -> dict[str, Any]:
|
|
|
112
114
|
result[name] = _json_safe(value)
|
|
113
115
|
return result
|
|
114
116
|
except Exception:
|
|
115
|
-
# This code runs at an error-transport boundary. A hostile __dict__,
|
|
116
|
-
# custom mapping, iterator or key must not replace the original failure
|
|
117
|
-
# with a serialization failure.
|
|
118
117
|
return {}
|
|
119
118
|
|
|
120
119
|
|
|
120
|
+
def _group_members(exc: BaseException) -> Sequence[BaseException] | None:
|
|
121
|
+
"""Return exception-group members without importing a 3.11-only symbol."""
|
|
122
|
+
if _EXCEPTION_GROUP_TYPE is None or not isinstance(exc, _EXCEPTION_GROUP_TYPE):
|
|
123
|
+
return None
|
|
124
|
+
try:
|
|
125
|
+
members: object = getattr(exc, "exceptions", None)
|
|
126
|
+
if not isinstance(members, tuple):
|
|
127
|
+
return None
|
|
128
|
+
if not all(isinstance(member, BaseException) for member in members):
|
|
129
|
+
return None
|
|
130
|
+
return members
|
|
131
|
+
except Exception:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _failure_record(exc: DataExceptError) -> dict[str, Any]:
|
|
136
|
+
"""Return failure metadata without letting hostile overrides escape."""
|
|
137
|
+
try:
|
|
138
|
+
metadata = exc.failure_metadata
|
|
139
|
+
failure = {
|
|
140
|
+
"kind": metadata.failure_kind,
|
|
141
|
+
"retryable": metadata.retryable,
|
|
142
|
+
"retry_after_seconds": metadata.retry_after_seconds,
|
|
143
|
+
}
|
|
144
|
+
if failure["kind"] not in {"transient", "permanent", "unknown"}:
|
|
145
|
+
raise ValueError("invalid failure kind")
|
|
146
|
+
if failure["retryable"] is not None and not isinstance(
|
|
147
|
+
failure["retryable"], bool
|
|
148
|
+
):
|
|
149
|
+
raise TypeError("invalid retryable value")
|
|
150
|
+
|
|
151
|
+
retry_after = failure["retry_after_seconds"]
|
|
152
|
+
if retry_after is not None:
|
|
153
|
+
if isinstance(retry_after, bool) or not isinstance(
|
|
154
|
+
retry_after, (int, float)
|
|
155
|
+
):
|
|
156
|
+
raise TypeError("invalid retry_after_seconds value")
|
|
157
|
+
retry_after = float(retry_after)
|
|
158
|
+
if not math.isfinite(retry_after) or retry_after < 0:
|
|
159
|
+
raise ValueError("invalid retry_after_seconds value")
|
|
160
|
+
failure["retry_after_seconds"] = retry_after
|
|
161
|
+
|
|
162
|
+
json.dumps(failure, allow_nan=False)
|
|
163
|
+
return failure
|
|
164
|
+
except Exception:
|
|
165
|
+
return dict(_UNKNOWN_FAILURE)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _failure_fields(exc: BaseException) -> dict[str, Any]:
|
|
169
|
+
"""Return the optional structured failure field for *exc*."""
|
|
170
|
+
if not isinstance(exc, DataExceptError):
|
|
171
|
+
return {}
|
|
172
|
+
return {"failure": _failure_record(exc)}
|
|
173
|
+
|
|
174
|
+
|
|
121
175
|
def _exception_record(
|
|
122
176
|
exc: BaseException,
|
|
123
177
|
*,
|
|
@@ -144,11 +198,25 @@ def _exception_record(
|
|
|
144
198
|
"module": type(exc).__module__,
|
|
145
199
|
"message": _safe_text(exc),
|
|
146
200
|
}
|
|
201
|
+
record.update(_failure_fields(exc))
|
|
147
202
|
if include_attributes:
|
|
148
203
|
attributes = _attributes(exc)
|
|
149
204
|
if attributes:
|
|
150
205
|
record["attributes"] = attributes
|
|
151
206
|
|
|
207
|
+
members = _group_members(exc)
|
|
208
|
+
if members is not None:
|
|
209
|
+
record["exceptions"] = [
|
|
210
|
+
_exception_record(
|
|
211
|
+
member,
|
|
212
|
+
include_attributes=include_attributes,
|
|
213
|
+
max_depth=max_depth,
|
|
214
|
+
depth=depth + 1,
|
|
215
|
+
seen=seen,
|
|
216
|
+
)
|
|
217
|
+
for member in members
|
|
218
|
+
]
|
|
219
|
+
|
|
152
220
|
if exc.__cause__ is not None:
|
|
153
221
|
record["cause"] = _exception_record(
|
|
154
222
|
exc.__cause__,
|
|
@@ -176,12 +244,7 @@ def exception_to_dict(
|
|
|
176
244
|
include_attributes: bool = True,
|
|
177
245
|
max_depth: int = 8,
|
|
178
246
|
) -> dict[str, Any]:
|
|
179
|
-
"""Return a strict JSON-safe structured representation of *exc*.
|
|
180
|
-
|
|
181
|
-
The representation contains the exception type, module and rendered
|
|
182
|
-
message, optionally public instance attributes, and bounded cause/context
|
|
183
|
-
chains. Traceback frames and private attributes are deliberately excluded.
|
|
184
|
-
"""
|
|
247
|
+
"""Return a strict JSON-safe structured representation of *exc*."""
|
|
185
248
|
if not isinstance(exc, BaseException):
|
|
186
249
|
raise TypeError("exc must be an exception instance")
|
|
187
250
|
if not isinstance(max_depth, int) or isinstance(max_depth, bool):
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Turning a third-party exception into a DataExcept one.
|
|
2
|
+
|
|
3
|
+
The pattern this replaces is everywhere in pipeline code::
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
frame = pd.read_csv(path)
|
|
7
|
+
except OSError as exc:
|
|
8
|
+
raise DataLoadingError(path, exc) from exc
|
|
9
|
+
|
|
10
|
+
It is easy to write and easy to get subtly wrong: forget the ``from exc`` and
|
|
11
|
+
the traceback stops showing what actually failed; pass the original to the
|
|
12
|
+
wrong parameter and it is not recorded at all; catch too broadly and a
|
|
13
|
+
``KeyboardInterrupt`` becomes a data-loading error.
|
|
14
|
+
|
|
15
|
+
:func:`wrap` and :func:`wrapping` do the same thing with the wiring settled.
|
|
16
|
+
New cause-aware constructors use the canonical keyword ``cause``. Legacy
|
|
17
|
+
``original`` and ``original_exception`` parameters remain supported, and
|
|
18
|
+
``__cause__`` is set either way so a traceback always shows both failures.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import contextlib
|
|
24
|
+
import inspect
|
|
25
|
+
from typing import Any, Iterator, Tuple, Type, Union
|
|
26
|
+
|
|
27
|
+
from .base import DataExceptError
|
|
28
|
+
from .failure_metadata import FailureMetadata
|
|
29
|
+
|
|
30
|
+
__all__ = ["wrap", "wrapping"]
|
|
31
|
+
|
|
32
|
+
_CAUSE_PARAMETERS = ("cause", "original", "original_exception")
|
|
33
|
+
|
|
34
|
+
Catchable = Union[Type[BaseException], Tuple[Type[BaseException], ...]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cause_parameter(target: Type[DataExceptError]) -> str | None:
|
|
38
|
+
"""Return the parameter of *target* that takes a wrapped exception."""
|
|
39
|
+
try:
|
|
40
|
+
parameters = inspect.signature(target.__init__).parameters
|
|
41
|
+
except (TypeError, ValueError): # pragma: no cover - builtins and C types
|
|
42
|
+
return None
|
|
43
|
+
for name in _CAUSE_PARAMETERS:
|
|
44
|
+
if name in parameters:
|
|
45
|
+
return name
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _explicit_cause_parameter(kwargs: dict[str, Any]) -> str | None:
|
|
50
|
+
"""Return an explicitly supplied canonical or legacy cause keyword."""
|
|
51
|
+
for name in _CAUSE_PARAMETERS:
|
|
52
|
+
if name in kwargs:
|
|
53
|
+
return name
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def wrap(
|
|
58
|
+
original: BaseException,
|
|
59
|
+
target: Type[DataExceptError],
|
|
60
|
+
/,
|
|
61
|
+
*,
|
|
62
|
+
failure_metadata: FailureMetadata | None = None,
|
|
63
|
+
**kwargs: Any,
|
|
64
|
+
) -> DataExceptError:
|
|
65
|
+
"""Build *target* from *original*, recording it as the cause.
|
|
66
|
+
|
|
67
|
+
Extra keyword arguments are passed through to the target constructor. If
|
|
68
|
+
the target accepts a cause parameter, *original* is injected unless the
|
|
69
|
+
caller already supplied ``cause``, ``original`` or ``original_exception``.
|
|
70
|
+
The resulting exception is always chained to *original* via ``__cause__``.
|
|
71
|
+
|
|
72
|
+
``failure_metadata`` optionally overrides the target class's conservative
|
|
73
|
+
default when the integration has backend-specific evidence about whether
|
|
74
|
+
the failure is transient or retryable.
|
|
75
|
+
"""
|
|
76
|
+
if failure_metadata is not None and not isinstance(
|
|
77
|
+
failure_metadata, FailureMetadata
|
|
78
|
+
):
|
|
79
|
+
raise TypeError("failure_metadata must be FailureMetadata or None")
|
|
80
|
+
|
|
81
|
+
if _explicit_cause_parameter(kwargs) is None:
|
|
82
|
+
parameter = _cause_parameter(target)
|
|
83
|
+
if parameter is not None:
|
|
84
|
+
kwargs[parameter] = original
|
|
85
|
+
|
|
86
|
+
exception = target(**kwargs)
|
|
87
|
+
exception.__cause__ = original
|
|
88
|
+
if failure_metadata is not None:
|
|
89
|
+
exception.with_failure_metadata(failure_metadata)
|
|
90
|
+
return exception
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@contextlib.contextmanager
|
|
94
|
+
def wrapping(
|
|
95
|
+
catch: Catchable,
|
|
96
|
+
target: Type[DataExceptError],
|
|
97
|
+
/,
|
|
98
|
+
*,
|
|
99
|
+
failure_metadata: FailureMetadata | None = None,
|
|
100
|
+
**kwargs: Any,
|
|
101
|
+
) -> Iterator[None]:
|
|
102
|
+
"""Translate *catch* raised inside the block into *target*."""
|
|
103
|
+
try:
|
|
104
|
+
yield
|
|
105
|
+
except catch as exc:
|
|
106
|
+
raise wrap(
|
|
107
|
+
exc,
|
|
108
|
+
target,
|
|
109
|
+
failure_metadata=failure_metadata,
|
|
110
|
+
**kwargs,
|
|
111
|
+
) from exc
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
"""Turning a third-party exception into a DataExcept one.
|
|
2
|
-
|
|
3
|
-
The pattern this replaces is everywhere in pipeline code::
|
|
4
|
-
|
|
5
|
-
try:
|
|
6
|
-
frame = pd.read_csv(path)
|
|
7
|
-
except OSError as exc:
|
|
8
|
-
raise DataLoadingError(path, exc) from exc
|
|
9
|
-
|
|
10
|
-
It is easy to write and easy to get subtly wrong: forget the ``from exc`` and
|
|
11
|
-
the traceback stops showing what actually failed; pass the original to the
|
|
12
|
-
wrong parameter and it is not recorded at all; catch too broadly and a
|
|
13
|
-
``KeyboardInterrupt`` becomes a data-loading error.
|
|
14
|
-
|
|
15
|
-
:func:`wrap` and :func:`wrapping` do the same thing with the wiring settled.
|
|
16
|
-
The original is passed to whichever constructor parameter takes a cause --
|
|
17
|
-
``original``, ``original_exception`` or ``cause``, whichever that class uses --
|
|
18
|
-
and set as ``__cause__`` either way, so a traceback always shows both.
|
|
19
|
-
"""
|
|
20
|
-
|
|
21
|
-
from __future__ import annotations
|
|
22
|
-
|
|
23
|
-
import contextlib
|
|
24
|
-
import inspect
|
|
25
|
-
from typing import Any, Iterator, Tuple, Type, Union
|
|
26
|
-
|
|
27
|
-
from .base import DataExceptError
|
|
28
|
-
|
|
29
|
-
__all__ = ["wrap", "wrapping"]
|
|
30
|
-
|
|
31
|
-
#: Constructor parameter names used across the package for a wrapped
|
|
32
|
-
#: exception. Checked in this order; the first the target accepts wins.
|
|
33
|
-
_CAUSE_PARAMETERS = ("original", "original_exception", "cause")
|
|
34
|
-
|
|
35
|
-
Catchable = Union[Type[BaseException], Tuple[Type[BaseException], ...]]
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def _cause_parameter(target: Type[DataExceptError]) -> str | None:
|
|
39
|
-
"""Return the parameter of *target* that takes a wrapped exception."""
|
|
40
|
-
try:
|
|
41
|
-
parameters = inspect.signature(target.__init__).parameters
|
|
42
|
-
except (TypeError, ValueError): # pragma: no cover - builtins and C types
|
|
43
|
-
return None
|
|
44
|
-
for name in _CAUSE_PARAMETERS:
|
|
45
|
-
if name in parameters:
|
|
46
|
-
return name
|
|
47
|
-
return None
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def wrap(
|
|
51
|
-
original: BaseException,
|
|
52
|
-
target: Type[DataExceptError],
|
|
53
|
-
/,
|
|
54
|
-
**kwargs: Any,
|
|
55
|
-
) -> DataExceptError:
|
|
56
|
-
"""Build *target* from *original*, recording it as the cause.
|
|
57
|
-
|
|
58
|
-
Extra keyword arguments go to the constructor::
|
|
59
|
-
|
|
60
|
-
raise wrap(exc, DataLoadingError, source=path) from exc
|
|
61
|
-
|
|
62
|
-
If *target* accepts a cause parameter, *original* is passed to it. Either
|
|
63
|
-
way ``__cause__`` is set, so a traceback shows the underlying failure even
|
|
64
|
-
for a class that records nothing.
|
|
65
|
-
|
|
66
|
-
An explicit ``original``/``cause`` keyword wins, so a caller can still say
|
|
67
|
-
exactly what they mean.
|
|
68
|
-
"""
|
|
69
|
-
parameter = _cause_parameter(target)
|
|
70
|
-
if parameter is not None and parameter not in kwargs:
|
|
71
|
-
kwargs[parameter] = original
|
|
72
|
-
|
|
73
|
-
exception = target(**kwargs)
|
|
74
|
-
# Set unconditionally: the target may record nothing, and the point is that
|
|
75
|
-
# the traceback shows what actually failed.
|
|
76
|
-
exception.__cause__ = original
|
|
77
|
-
return exception
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
@contextlib.contextmanager
|
|
81
|
-
def wrapping(
|
|
82
|
-
catch: Catchable,
|
|
83
|
-
target: Type[DataExceptError],
|
|
84
|
-
/,
|
|
85
|
-
**kwargs: Any,
|
|
86
|
-
) -> Iterator[None]:
|
|
87
|
-
"""Translate *catch* raised inside the block into *target*.
|
|
88
|
-
|
|
89
|
-
::
|
|
90
|
-
|
|
91
|
-
with wrapping(OSError, DataLoadingError, source=path):
|
|
92
|
-
frame = pd.read_csv(path)
|
|
93
|
-
|
|
94
|
-
Only exceptions matching *catch* are translated; everything else propagates
|
|
95
|
-
untouched, including anything already raised by this package. Because
|
|
96
|
-
*catch* is given explicitly there is no default broad ``except``, so a
|
|
97
|
-
``KeyboardInterrupt`` or a bug in the block is never relabelled as a data
|
|
98
|
-
error.
|
|
99
|
-
"""
|
|
100
|
-
try:
|
|
101
|
-
yield
|
|
102
|
-
except catch as exc:
|
|
103
|
-
raise wrap(exc, target, **kwargs) from exc
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|