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.
- {dataexcept-1.3.0 → dataexcept-1.5.0}/CHANGELOG.md +100 -2
- {dataexcept-1.3.0 → dataexcept-1.5.0}/CITATION.cff +2 -2
- {dataexcept-1.3.0 → dataexcept-1.5.0}/PKG-INFO +21 -1
- {dataexcept-1.3.0 → dataexcept-1.5.0}/README.md +20 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/__init__.py +11 -8
- dataexcept-1.5.0/dataexcept/_causes.py +31 -0
- dataexcept-1.5.0/dataexcept/_previews.py +74 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/base.py +31 -45
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/database_exceptions.py +19 -5
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/authentication.py +11 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/external.py +24 -5
- dataexcept-1.5.0/dataexcept/exceptions/parsing.py +140 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/validation.py +6 -0
- dataexcept-1.5.0/dataexcept/failure_metadata.py +45 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/pipeline_exceptions.py +15 -3
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/redaction.py +11 -1
- dataexcept-1.5.0/dataexcept/schema.py +61 -0
- dataexcept-1.5.0/dataexcept/schemas/envelope-1.0.0.json +109 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/serialization.py +49 -14
- dataexcept-1.5.0/dataexcept/wrapping.py +111 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/pyproject.toml +5 -1
- dataexcept-1.3.0/dataexcept/exceptions/parsing.py +0 -31
- dataexcept-1.3.0/dataexcept/wrapping.py +0 -103
- {dataexcept-1.3.0 → dataexcept-1.5.0}/LICENSE +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/__main__.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/_validation.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/dataengineering_exceptions.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/datascience_exceptions/__init__.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/datascience_exceptions/base.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/datascience_exceptions/ingestion.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/datascience_exceptions/operations.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/datascience_exceptions/training.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/__init__.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/base.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/configuration.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/lifecycle.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/notification.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/exceptions/scheduling.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/io_exceptions.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/logging_helpers.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/network_exceptions.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/pandas_exceptions.py +0 -0
- {dataexcept-1.3.0 → dataexcept-1.5.0}/dataexcept/py.typed +0 -0
- {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
|
|
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.
|
|
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.
|
|
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-
|
|
14
|
+
date-released: "2026-09-06"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: DataExcept
|
|
3
|
-
Version: 1.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|