DataExcept 1.3.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.3.0 → dataexcept-1.4.0}/CHANGELOG.md +43 -2
- {dataexcept-1.3.0 → dataexcept-1.4.0}/CITATION.cff +2 -2
- {dataexcept-1.3.0 → dataexcept-1.4.0}/PKG-INFO +1 -1
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/__init__.py +3 -8
- dataexcept-1.4.0/dataexcept/_causes.py +31 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/base.py +31 -45
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/database_exceptions.py +19 -5
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/authentication.py +11 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/external.py +24 -5
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/validation.py +6 -0
- dataexcept-1.4.0/dataexcept/failure_metadata.py +45 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/pipeline_exceptions.py +15 -3
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/serialization.py +49 -14
- dataexcept-1.4.0/dataexcept/wrapping.py +111 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/pyproject.toml +1 -1
- dataexcept-1.3.0/dataexcept/wrapping.py +0 -103
- {dataexcept-1.3.0 → dataexcept-1.4.0}/LICENSE +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/README.md +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/__main__.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/_validation.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/dataengineering_exceptions.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/__init__.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/base.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/ingestion.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/operations.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/training.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/__init__.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/base.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/configuration.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/lifecycle.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/notification.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/parsing.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/scheduling.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/io_exceptions.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/logging_helpers.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/network_exceptions.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/pandas_exceptions.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/py.typed +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/redaction.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/security_exceptions.py +0 -0
|
@@ -7,6 +7,46 @@ 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
|
+
|
|
10
50
|
## [1.3.0] - 2026-09-03
|
|
11
51
|
|
|
12
52
|
### Added
|
|
@@ -188,7 +228,7 @@ and the [migration guide](https://diogoribeiro7.github.io/DataExcept/migration/)
|
|
|
188
228
|
- Two classes assigned their attributes *after* calling `super().__init__`, so
|
|
189
229
|
the sweep never saw them. Both now assign first, and a test fails if any
|
|
190
230
|
constructor does it again.
|
|
191
|
-
- The URL pattern carried a
|
|
231
|
+
- The URL pattern carried a `\b` anchor, so a URL directly following a word
|
|
192
232
|
character was never matched — including `feature_https://...`, the step name
|
|
193
233
|
`FeaturePreprocessingError` builds from its own argument.
|
|
194
234
|
|
|
@@ -570,7 +610,8 @@ First public release.
|
|
|
570
610
|
- Published to PyPI via OIDC trusted publishing; no long-lived API token is
|
|
571
611
|
involved in a release.
|
|
572
612
|
|
|
573
|
-
[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
|
|
574
615
|
[1.3.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.2.0...v1.3.0
|
|
575
616
|
[1.2.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.1.0...v1.2.0
|
|
576
617
|
[1.1.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.0.0...v1.1.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"
|
|
@@ -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:
|
|
@@ -8,6 +8,7 @@ import math
|
|
|
8
8
|
from collections.abc import Mapping, Sequence, Set
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
+
from .base import DataExceptError
|
|
11
12
|
from .redaction import redact_urls_in_text
|
|
12
13
|
|
|
13
14
|
__all__ = ["exception_to_dict", "exception_to_json"]
|
|
@@ -15,16 +16,15 @@ __all__ = ["exception_to_dict", "exception_to_json"]
|
|
|
15
16
|
_MAX_VALUE_DEPTH = 8
|
|
16
17
|
_NOT_SCALAR = object()
|
|
17
18
|
_EXCEPTION_GROUP_TYPE = getattr(builtins, "BaseExceptionGroup", None)
|
|
19
|
+
_UNKNOWN_FAILURE = {
|
|
20
|
+
"kind": "unknown",
|
|
21
|
+
"retryable": None,
|
|
22
|
+
"retry_after_seconds": None,
|
|
23
|
+
}
|
|
18
24
|
|
|
19
25
|
|
|
20
26
|
def _redact_export_text(text: str) -> str:
|
|
21
|
-
"""Scrub URLs for export, including their paths.
|
|
22
|
-
|
|
23
|
-
Normal DataExcept messages preserve URL paths because paths are commonly
|
|
24
|
-
useful debugging context. Structured envelopes have a stricter boundary:
|
|
25
|
-
third-party errors and arbitrary caller state may put credentials in the
|
|
26
|
-
path itself, so exported text never preserves URL paths.
|
|
27
|
-
"""
|
|
27
|
+
"""Scrub URLs for export, including their paths."""
|
|
28
28
|
return redact_urls_in_text(text, keep_path=False)
|
|
29
29
|
|
|
30
30
|
|
|
@@ -132,6 +132,46 @@ def _group_members(exc: BaseException) -> Sequence[BaseException] | None:
|
|
|
132
132
|
return None
|
|
133
133
|
|
|
134
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
|
+
|
|
135
175
|
def _exception_record(
|
|
136
176
|
exc: BaseException,
|
|
137
177
|
*,
|
|
@@ -158,6 +198,7 @@ def _exception_record(
|
|
|
158
198
|
"module": type(exc).__module__,
|
|
159
199
|
"message": _safe_text(exc),
|
|
160
200
|
}
|
|
201
|
+
record.update(_failure_fields(exc))
|
|
161
202
|
if include_attributes:
|
|
162
203
|
attributes = _attributes(exc)
|
|
163
204
|
if attributes:
|
|
@@ -203,13 +244,7 @@ def exception_to_dict(
|
|
|
203
244
|
include_attributes: bool = True,
|
|
204
245
|
max_depth: int = 8,
|
|
205
246
|
) -> dict[str, Any]:
|
|
206
|
-
"""Return a strict JSON-safe structured representation of *exc*.
|
|
207
|
-
|
|
208
|
-
The representation contains the exception type, module and rendered
|
|
209
|
-
message, optionally public instance attributes, bounded cause/context
|
|
210
|
-
chains, and on Python 3.11+ the member tree of exception groups. Traceback
|
|
211
|
-
frames and private attributes are deliberately excluded.
|
|
212
|
-
"""
|
|
247
|
+
"""Return a strict JSON-safe structured representation of *exc*."""
|
|
213
248
|
if not isinstance(exc, BaseException):
|
|
214
249
|
raise TypeError("exc must be an exception instance")
|
|
215
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
|
|
File without changes
|