DataExcept 1.3.0__tar.gz → 1.5.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 (44) hide show
  1. {dataexcept-1.3.0 → dataexcept-1.5.0}/CHANGELOG.md +100 -2
  2. {dataexcept-1.3.0 → dataexcept-1.5.0}/CITATION.cff +2 -2
  3. {dataexcept-1.3.0 → dataexcept-1.5.0}/PKG-INFO +21 -1
  4. {dataexcept-1.3.0 → dataexcept-1.5.0}/README.md +20 -0
  5. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/__init__.py +11 -8
  6. dataexcept-1.5.0/dataexcept/_causes.py +31 -0
  7. dataexcept-1.5.0/dataexcept/_previews.py +74 -0
  8. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/base.py +31 -45
  9. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/database_exceptions.py +19 -5
  10. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/authentication.py +11 -0
  11. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/external.py +24 -5
  12. dataexcept-1.5.0/dataexcept/exceptions/parsing.py +140 -0
  13. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/validation.py +6 -0
  14. dataexcept-1.5.0/dataexcept/failure_metadata.py +45 -0
  15. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/pipeline_exceptions.py +15 -3
  16. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/redaction.py +11 -1
  17. dataexcept-1.5.0/dataexcept/schema.py +61 -0
  18. dataexcept-1.5.0/dataexcept/schemas/envelope-1.0.0.json +109 -0
  19. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/serialization.py +49 -14
  20. dataexcept-1.5.0/dataexcept/wrapping.py +111 -0
  21. {dataexcept-1.3.0 → dataexcept-1.5.0}/pyproject.toml +5 -1
  22. dataexcept-1.3.0/dataexcept/exceptions/parsing.py +0 -31
  23. dataexcept-1.3.0/dataexcept/wrapping.py +0 -103
  24. {dataexcept-1.3.0 → dataexcept-1.5.0}/LICENSE +0 -0
  25. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/__main__.py +0 -0
  26. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/_validation.py +0 -0
  27. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/dataengineering_exceptions.py +0 -0
  28. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/datascience_exceptions/__init__.py +0 -0
  29. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/datascience_exceptions/base.py +0 -0
  30. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/datascience_exceptions/ingestion.py +0 -0
  31. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/datascience_exceptions/operations.py +0 -0
  32. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/datascience_exceptions/training.py +0 -0
  33. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/__init__.py +0 -0
  34. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/base.py +0 -0
  35. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/configuration.py +0 -0
  36. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/lifecycle.py +0 -0
  37. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/notification.py +0 -0
  38. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/scheduling.py +0 -0
  39. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/io_exceptions.py +0 -0
  40. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/logging_helpers.py +0 -0
  41. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/network_exceptions.py +0 -0
  42. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/pandas_exceptions.py +0 -0
  43. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/py.typed +0 -0
  44. {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/security_exceptions.py +0 -0
@@ -7,6 +7,102 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.0] - 2026-09-06
11
+
12
+ ### Added
13
+
14
+ - **Redaction-safe source context for parsing failures.** `ParsingError` and
15
+ `DeserializationError` accept keyword-only `source`, `format`, `preview` and
16
+ `cause`, so a failure on untrusted content can be described without keeping
17
+ the content. `source` is redacted when it is a URL and left alone when it is
18
+ a file path; `preview` is bounded to 200 characters including its truncation
19
+ marker, decodes bytes as UTF-8
20
+ with any undecodable byte shown as `\xNN`, and is redacted like any other
21
+ public attribute.
22
+ - `cause=` on both classes, following the canonical 1.4.0 contract: recorded as
23
+ `.cause` and set as `__cause__`.
24
+ - **A published JSON Schema for the structured envelope.** The payload
25
+ `exception_to_dict()` and `exception_to_json()` produce is now a versioned,
26
+ language-neutral contract rather than prose. `envelope_schema()`,
27
+ `ENVELOPE_SCHEMA_VERSION` and `ENVELOPE_SCHEMA_ID` expose it from the top
28
+ level; the document ships inside the package and is published at its `$id`,
29
+ so a consumer in another language can fetch it without installing anything
30
+ Python.
31
+ - Reference envelope fixtures covering ordinary exceptions, explicit causes,
32
+ failure metadata, implicit contexts, nested exception groups, redaction,
33
+ truncation and cycles. Each is produced by running the real serializer over a
34
+ real exception, and CI fails if a committed fixture stops matching what the
35
+ serializer emits.
36
+ - Compatibility tests that validate an emitted envelope against the schema for
37
+ every exception class the package defines, and that reject malformed
38
+ envelopes so the schema cannot quietly become vacuous.
39
+
40
+ The envelope shape itself is unchanged here: this release writes down and tests
41
+ the payload as it already stands. That payload has been emitted since 1.2.0 and
42
+ reached its current shape in 1.4.0, having gained `exceptions` in 1.3.0 and
43
+ `failure` in 1.4.0; schema 1.0.0 describes that shape. The schema is versioned
44
+ separately from the package, and DataExcept still takes no runtime dependency on
45
+ a validator — it publishes the contract rather than checking its own output.
46
+
47
+ ### Changed
48
+
49
+ - `ParsingError.text` and `DeserializationError.data` are now optional, so
50
+ neither class requires the payload that caused the failure. Existing
51
+ positional calls are unaffected, and a payload that is passed is still kept
52
+ verbatim.
53
+ - `ParsingError`'s generated message bounds the payload it quotes, so a large
54
+ malformed input no longer becomes a log line of the same size. The payload
55
+ itself remains in full on `.text`.
56
+
57
+ ### Fixed
58
+
59
+ - A URL that ends a sentence no longer swallows the punctuation that ended it.
60
+ A full stop, colon, exclamation mark or question mark is legal inside a URL,
61
+ so the matcher ran through it and gave the character to the redacted URL:
62
+ `"fetch <url>: connection refused"` came back without its separator. The
63
+ match now runs through such characters but cannot end on one. Commas,
64
+ semicolons, brackets and quotes already terminated a match and are unchanged.
65
+
66
+ ## [1.4.0] - 2026-09-04
67
+
68
+ ### Added
69
+
70
+ - **A canonical cause contract for operational exceptions.** New code can use
71
+ keyword-only `cause=` and the public `.cause` attribute, while existing
72
+ `original=` and `original_exception=` APIs remain supported where they were
73
+ already public. Accepted causes populate `__cause__`, survive pickling and
74
+ structured serialization, and invalid or ambiguous aliases fail fast.
75
+ - **Machine-readable failure metadata.** Every `DataExceptError` exposes
76
+ `failure_kind`, `retryable`, `retry_after_seconds` and the immutable
77
+ `FailureMetadata` value object. Validation and authentication/authorization
78
+ failures have conservative permanent/non-retryable defaults; generic
79
+ infrastructure failures remain unknown unless backend evidence says more.
80
+ - `wrap()` and `wrapping()` accept `failure_metadata=` so integrations can
81
+ attach backend-informed retryability without parsing messages or changing
82
+ exception constructors.
83
+ - Structured envelopes now include a stable `failure` object carrying failure
84
+ kind, retryability and optional retry delay.
85
+
86
+ ### Changed
87
+
88
+ - `wrap()` now prefers the canonical `cause` keyword when a target supports it,
89
+ while preserving explicit legacy cause overrides and always chaining the
90
+ actual wrapped exception through `__cause__`.
91
+ - Release automation now uses one permanent privileged Release workflow. Normal
92
+ release preparation happens through ordinary branches and pull requests;
93
+ publication is requested only after the reviewed release commit reaches
94
+ protected `main`.
95
+
96
+ ### Fixed
97
+
98
+ - Failure-metadata serialization now preserves the serializer's never-throw
99
+ contract even for hostile or malformed custom accessors. Invalid kinds,
100
+ retryability values, string or boolean delays, negative delays and non-finite
101
+ delays all fall back to the canonical unknown failure record; valid numeric
102
+ delays are normalised to `float`.
103
+ - Cause-aware wrapping no longer injects a second canonical cause when a caller
104
+ already supplied a legacy `original` or `original_exception` override.
105
+
10
106
  ## [1.3.0] - 2026-09-03
11
107
 
12
108
  ### Added
@@ -188,7 +284,7 @@ and the [migration guide](https://diogoribeiro7.github.io/DataExcept/migration/)
188
284
  - Two classes assigned their attributes *after* calling `super().__init__`, so
189
285
  the sweep never saw them. Both now assign first, and a test fails if any
190
286
  constructor does it again.
191
- - The URL pattern carried a `` anchor, so a URL directly following a word
287
+ - The URL pattern carried a `\b` anchor, so a URL directly following a word
192
288
  character was never matched — including `feature_https://...`, the step name
193
289
  `FeaturePreprocessingError` builds from its own argument.
194
290
 
@@ -570,7 +666,9 @@ First public release.
570
666
  - Published to PyPI via OIDC trusted publishing; no long-lived API token is
571
667
  involved in a release.
572
668
 
573
- [Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.3.0...HEAD
669
+ [Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.5.0...HEAD
670
+ [1.5.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.4.0...v1.5.0
671
+ [1.4.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.3.0...v1.4.0
574
672
  [1.3.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.2.0...v1.3.0
575
673
  [1.2.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.1.0...v1.2.0
576
674
  [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.5.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-06"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DataExcept
3
- Version: 1.3.0
3
+ Version: 1.5.0
4
4
  Summary: A Python package providing structured, easily-extendable custom exception types.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -45,6 +45,7 @@ Description-Content-Type: text/markdown
45
45
  - **🏗️ Hierarchical Structure**: Catch one specific error, a whole domain, or every operational error via `DataExceptError`
46
46
  - **📦 One Import**: Every exception is available from `dataexcept` directly, or from its domain module — same objects either way
47
47
  - **📊 Data Science Focused**: 100 exception classes covering ML pipelines, feature engineering, model training
48
+ - **🧭 Language-neutral envelopes**: exceptions export to strict JSON against a published, versioned schema — so a non-Python consumer can validate what it receives
48
49
  - **🔧 Production Ready**: Logging helpers, error context, and exceptions that pickle — so they cross a process boundary with their message, attributes and cause intact
49
50
  - **📚 Academic Quality**: Proper documentation, type hints, and citation support
50
51
  - **🐍 Python 3.10 – 3.14**: Every supported version tested in CI, with full type safety
@@ -211,6 +212,24 @@ except Exception as exc:
211
212
  raise
212
213
  ```
213
214
 
215
+ ### Structured Error Envelopes
216
+
217
+ ```python
218
+ from dataexcept import ValidationError, exception_to_json
219
+
220
+ try:
221
+ validate_row(row)
222
+ except ValidationError as exc:
223
+ # Strict JSON: identity, message, public attributes, failure metadata and
224
+ # the cause chain, with credential-bearing URLs already redacted.
225
+ logger.error(exception_to_json(exc, sort_keys=True))
226
+ ```
227
+
228
+ The payload shape is a versioned, language-neutral contract, published as a
229
+ [JSON Schema](https://diogoribeiro7.github.io/DataExcept/envelope_schema/) with
230
+ reference fixtures, so a Node.js or Go consumer can be tested against the same
231
+ document Python emits.
232
+
214
233
  ### Command Line Interface
215
234
 
216
235
  ```bash
@@ -364,6 +383,7 @@ through [SECURITY.md](SECURITY.md), not the public issue tracker.
364
383
  - **API Stability**: [What is public and what may change](https://diogoribeiro7.github.io/DataExcept/stability/)
365
384
  - **Upgrading from 0.x**: [Migration guide](https://diogoribeiro7.github.io/DataExcept/migration/)
366
385
  - **Advanced Usage**: [Advanced Guide](https://diogoribeiro7.github.io/DataExcept/advanced_usage/)
386
+ - **Envelope Schema**: [The published envelope contract](https://diogoribeiro7.github.io/DataExcept/envelope_schema/)
367
387
  - **CLI Reference**: [CLI Guide](https://diogoribeiro7.github.io/DataExcept/cli/)
368
388
  - **Changelog**: [CHANGELOG.md](CHANGELOG.md)
369
389
 
@@ -18,6 +18,7 @@
18
18
  - **🏗️ Hierarchical Structure**: Catch one specific error, a whole domain, or every operational error via `DataExceptError`
19
19
  - **📦 One Import**: Every exception is available from `dataexcept` directly, or from its domain module — same objects either way
20
20
  - **📊 Data Science Focused**: 100 exception classes covering ML pipelines, feature engineering, model training
21
+ - **🧭 Language-neutral envelopes**: exceptions export to strict JSON against a published, versioned schema — so a non-Python consumer can validate what it receives
21
22
  - **🔧 Production Ready**: Logging helpers, error context, and exceptions that pickle — so they cross a process boundary with their message, attributes and cause intact
22
23
  - **📚 Academic Quality**: Proper documentation, type hints, and citation support
23
24
  - **🐍 Python 3.10 – 3.14**: Every supported version tested in CI, with full type safety
@@ -184,6 +185,24 @@ except Exception as exc:
184
185
  raise
185
186
  ```
186
187
 
188
+ ### Structured Error Envelopes
189
+
190
+ ```python
191
+ from dataexcept import ValidationError, exception_to_json
192
+
193
+ try:
194
+ validate_row(row)
195
+ except ValidationError as exc:
196
+ # Strict JSON: identity, message, public attributes, failure metadata and
197
+ # the cause chain, with credential-bearing URLs already redacted.
198
+ logger.error(exception_to_json(exc, sort_keys=True))
199
+ ```
200
+
201
+ The payload shape is a versioned, language-neutral contract, published as a
202
+ [JSON Schema](https://diogoribeiro7.github.io/DataExcept/envelope_schema/) with
203
+ reference fixtures, so a Node.js or Go consumer can be tested against the same
204
+ document Python emits.
205
+
187
206
  ### Command Line Interface
188
207
 
189
208
  ```bash
@@ -337,6 +356,7 @@ through [SECURITY.md](SECURITY.md), not the public issue tracker.
337
356
  - **API Stability**: [What is public and what may change](https://diogoribeiro7.github.io/DataExcept/stability/)
338
357
  - **Upgrading from 0.x**: [Migration guide](https://diogoribeiro7.github.io/DataExcept/migration/)
339
358
  - **Advanced Usage**: [Advanced Guide](https://diogoribeiro7.github.io/DataExcept/advanced_usage/)
359
+ - **Envelope Schema**: [The published envelope contract](https://diogoribeiro7.github.io/DataExcept/envelope_schema/)
340
360
  - **CLI Reference**: [CLI Guide](https://diogoribeiro7.github.io/DataExcept/cli/)
341
361
  - **Changelog**: [CHANGELOG.md](CHANGELOG.md)
342
362
 
@@ -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,
@@ -153,6 +154,11 @@ from .pipeline_exceptions import (
153
154
  TimeDeltaTooLargeError,
154
155
  TypeCheckError,
155
156
  )
157
+ from .schema import (
158
+ ENVELOPE_SCHEMA_ID,
159
+ ENVELOPE_SCHEMA_VERSION,
160
+ envelope_schema,
161
+ )
156
162
  from .security_exceptions import (
157
163
  DecryptionError,
158
164
  EncryptionError,
@@ -163,13 +169,11 @@ from .serialization import exception_to_dict, exception_to_json
163
169
  from .wrapping import wrap, wrapping
164
170
 
165
171
  __all__ = [
166
- # The root of the hierarchy: catches every operational exception the
167
- # package raises.
168
172
  "DataExceptError",
169
- # Placeholders for state that could not survive serialization.
170
173
  "UnpicklableCause",
171
174
  "UnpicklableValue",
172
- # Every exception class the package defines.
175
+ "FailureKind",
176
+ "FailureMetadata",
173
177
  "ApiError",
174
178
  "AuthenticationError",
175
179
  "AuthorizationError",
@@ -268,18 +272,17 @@ __all__ = [
268
272
  "UnderfittingError",
269
273
  "ValidationError",
270
274
  "WebhookError",
271
- # Turning a third-party exception into one of these.
272
275
  "wrap",
273
276
  "wrapping",
274
- # Structured serialization for APIs, queues and telemetry.
275
277
  "exception_to_dict",
276
278
  "exception_to_json",
277
- # Logging helpers.
279
+ "ENVELOPE_SCHEMA_ID",
280
+ "ENVELOPE_SCHEMA_VERSION",
281
+ "envelope_schema",
278
282
  "Context",
279
283
  "log_and_raise",
280
284
  "log_exception",
281
285
  "log_then_raise",
282
- # Domain modules, for callers who prefer a qualified import.
283
286
  "database_exceptions",
284
287
  "dataengineering_exceptions",
285
288
  "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
@@ -0,0 +1,74 @@
1
+ """Bounded excerpts of a payload that failed to parse.
2
+
3
+ A parsing failure happens on content the caller did not write and often did not
4
+ choose: an API response, a queue message, a file from somewhere else. Storing
5
+ the whole offending payload on the exception drags it into every log line,
6
+ envelope and telemetry event that touches the failure -- including whatever
7
+ credentials or personal data the payload happened to contain.
8
+
9
+ The excerpt is the compromise. It is short enough to read in a log and long
10
+ enough to show what the parser choked on, and because it is an ordinary string
11
+ attribute it goes through the same URL redaction as every other public
12
+ attribute on a DataExcept exception.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ __all__ = ["MAX_PREVIEW_LENGTH", "TRUNCATION_MARKER", "bounded_preview"]
18
+
19
+ #: Longest excerpt kept, in characters, counting the truncation marker. A
20
+ #: caller who wants less can pass less; there is deliberately no way to ask for
21
+ #: more, because the point of the field is that an untrusted payload cannot
22
+ #: reach a log at its own length.
23
+ MAX_PREVIEW_LENGTH = 200
24
+
25
+ #: Appended when anything was cut, and only then.
26
+ TRUNCATION_MARKER = "..."
27
+
28
+ #: Bytes decoded before the excerpt is measured. Four is the longest a UTF-8
29
+ #: character gets, so this always yields at least MAX_PREVIEW_LENGTH characters
30
+ #: when the payload has them, without decoding a payload of any size.
31
+ _BYTE_WINDOW = MAX_PREVIEW_LENGTH * 4
32
+
33
+
34
+ def _as_text(value: str | bytes | bytearray) -> tuple[str, bool]:
35
+ """Return *value* as text, and whether reading it already cut something."""
36
+ if isinstance(value, str):
37
+ return value, False
38
+ head = bytes(value[:_BYTE_WINDOW])
39
+ # backslashreplace, not replace: an excerpt of a malformed payload is a
40
+ # best effort by definition and must not fail where the parser did, but it
41
+ # should stay printable. U+FFFD is neither readable nor writable on a
42
+ # console that is not UTF-8, while `\xff` says which byte was wrong.
43
+ return head.decode("utf-8", errors="backslashreplace"), len(value) > len(head)
44
+
45
+
46
+ def bounded_preview(value: str | bytes | bytearray | None) -> str | None:
47
+ r"""Return at most :data:`MAX_PREVIEW_LENGTH` characters of *value*.
48
+
49
+ ``None`` gives ``None``: no excerpt was asked for. Bytes are decoded as
50
+ UTF-8, with any undecodable byte shown as ``\xNN``.
51
+ :data:`TRUNCATION_MARKER` is appended when, and only when, something was
52
+ left out, and the result stays within the bound either way.
53
+
54
+ The bound is on the payload. Redaction runs afterwards, over this value as
55
+ over every other public attribute, and a credential shorter than its
56
+ replacement makes the stored string a few characters longer. Chasing an
57
+ exact count through a pass whose job is to remove secrets rather than
58
+ preserve lengths would buy nothing: the excerpt is bounded so that an
59
+ untrusted payload cannot reach a log at its own size, and it does that.
60
+ """
61
+ if value is None:
62
+ return None
63
+ if not isinstance(value, (str, bytes, bytearray)):
64
+ raise TypeError(
65
+ f"preview must be str, bytes, or None, got {type(value).__name__}"
66
+ )
67
+
68
+ text, truncated = _as_text(value)
69
+ if not truncated and len(text) <= MAX_PREVIEW_LENGTH:
70
+ return text
71
+ # Something was left out, whether by the decode window or by length. Cut
72
+ # short enough for the marker to fit inside the bound rather than on top of
73
+ # it: a documented maximum the value can exceed is not a maximum.
74
+ return text[: MAX_PREVIEW_LENGTH - len(TRUNCATION_MARKER)] + TRUNCATION_MARKER
@@ -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