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.
Files changed (40) hide show
  1. {dataexcept-1.3.0 → dataexcept-1.4.0}/CHANGELOG.md +43 -2
  2. {dataexcept-1.3.0 → dataexcept-1.4.0}/CITATION.cff +2 -2
  3. {dataexcept-1.3.0 → dataexcept-1.4.0}/PKG-INFO +1 -1
  4. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/__init__.py +3 -8
  5. dataexcept-1.4.0/dataexcept/_causes.py +31 -0
  6. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/base.py +31 -45
  7. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/database_exceptions.py +19 -5
  8. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/authentication.py +11 -0
  9. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/external.py +24 -5
  10. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/validation.py +6 -0
  11. dataexcept-1.4.0/dataexcept/failure_metadata.py +45 -0
  12. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/pipeline_exceptions.py +15 -3
  13. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/serialization.py +49 -14
  14. dataexcept-1.4.0/dataexcept/wrapping.py +111 -0
  15. {dataexcept-1.3.0 → dataexcept-1.4.0}/pyproject.toml +1 -1
  16. dataexcept-1.3.0/dataexcept/wrapping.py +0 -103
  17. {dataexcept-1.3.0 → dataexcept-1.4.0}/LICENSE +0 -0
  18. {dataexcept-1.3.0 → dataexcept-1.4.0}/README.md +0 -0
  19. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/__main__.py +0 -0
  20. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/_validation.py +0 -0
  21. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/dataengineering_exceptions.py +0 -0
  22. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/__init__.py +0 -0
  23. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/base.py +0 -0
  24. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/ingestion.py +0 -0
  25. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/operations.py +0 -0
  26. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/datascience_exceptions/training.py +0 -0
  27. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/__init__.py +0 -0
  28. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/base.py +0 -0
  29. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/configuration.py +0 -0
  30. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/lifecycle.py +0 -0
  31. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/notification.py +0 -0
  32. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/parsing.py +0 -0
  33. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/exceptions/scheduling.py +0 -0
  34. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/io_exceptions.py +0 -0
  35. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/logging_helpers.py +0 -0
  36. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/network_exceptions.py +0 -0
  37. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/pandas_exceptions.py +0 -0
  38. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/py.typed +0 -0
  39. {dataexcept-1.3.0 → dataexcept-1.4.0}/dataexcept/redaction.py +0 -0
  40. {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 `` anchor, so a URL directly following a word
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.3.0...HEAD
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.3.0"
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-03"
14
+ date-released: "2026-09-04"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DataExcept
3
- Version: 1.3.0
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
@@ -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
- # Every exception class the package defines.
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 - a repr that itself raises
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__(self, query: str, original: Exception | None = None) -> None:
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: Optional underlying exception.
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 = original
51
+ self.original = resolved
52
+ self.cause = resolved
43
53
  msg = f"Query failed: {query}"
44
- if original:
45
- msg += f" ({original})"
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__(self, service_name: str, original_exception: Exception | None = None):
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 = original_exception
20
+ self.original_exception = resolved
21
+ self.cause = resolved
10
22
  msg = f"Failed to connect to service '{service_name}'"
11
- if original_exception:
12
- msg += f": {original_exception}"
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__(self, operation: str, timeout: float):
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,6 +1,6 @@
1
1
  [project]
2
2
  name = "DataExcept"
3
- version = "1.3.0"
3
+ version = "1.4.0"
4
4
  description = "A Python package providing structured, easily-extendable custom exception types."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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