DataExcept 0.4.0__tar.gz → 0.4.2__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-0.4.0 → dataexcept-0.4.2}/CHANGELOG.md +165 -2
- {dataexcept-0.4.0 → dataexcept-0.4.2}/CITATION.cff +2 -2
- {dataexcept-0.4.0 → dataexcept-0.4.2}/PKG-INFO +11 -10
- {dataexcept-0.4.0 → dataexcept-0.4.2}/README.md +8 -8
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/__init__.py +6 -2
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/_validation.py +7 -1
- dataexcept-0.4.2/dataexcept/base.py +195 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/dataengineering_exceptions.py +2 -1
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/datascience_exceptions/ingestion.py +2 -1
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/datascience_exceptions/operations.py +2 -1
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/datascience_exceptions/training.py +3 -1
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/notification.py +7 -2
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/io_exceptions.py +4 -3
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/logging_helpers.py +41 -1
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/pandas_exceptions.py +7 -5
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/pipeline_exceptions.py +6 -4
- dataexcept-0.4.2/dataexcept/redaction.py +240 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/security_exceptions.py +4 -2
- {dataexcept-0.4.0 → dataexcept-0.4.2}/pyproject.toml +7 -2
- dataexcept-0.4.0/dataexcept/base.py +0 -67
- dataexcept-0.4.0/dataexcept/redaction.py +0 -114
- {dataexcept-0.4.0 → dataexcept-0.4.2}/LICENSE +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/__main__.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/_deprecation.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/database_exceptions.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/datascience_exceptions/__init__.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/datascience_exceptions/base.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/__init__.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/authentication.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/base.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/configuration.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/external.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/lifecycle.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/parsing.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/scheduling.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/exceptions/validation.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/job_exceptions.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/network_exceptions.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.2}/dataexcept/py.typed +0 -0
|
@@ -7,6 +7,168 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.2] - 2026-08-25
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- `SECURITY.md` states two further boundaries rather than leaving them to be
|
|
15
|
+
discovered: state attached to an exception *after* it is constructed is not
|
|
16
|
+
swept, and a URL nested inside a value you pass is rendered redacted but the
|
|
17
|
+
object itself is not rewritten. Walking and rewriting arbitrary caller data
|
|
18
|
+
structures would be a surprising thing for an exception library to do, and
|
|
19
|
+
could not be complete anyway.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **Sensitive query parameters are matched by token, not substring.** The
|
|
24
|
+
substring rule was wrong in both directions: it redacted `monkey`, `design`,
|
|
25
|
+
`assign`, `keyword` and `authors`, mangling ordinary debugging information,
|
|
26
|
+
while still missing `passphrase`. Parameter names are now split on
|
|
27
|
+
separators and camelCase and matched word by word, so `X-Amz-Signature`,
|
|
28
|
+
`accessToken` and `client_secret` are caught and `?monkey=bobo` is left
|
|
29
|
+
alone.
|
|
30
|
+
|
|
31
|
+
### Security
|
|
32
|
+
|
|
33
|
+
- **`jwt`, `bearer`, `hmac` and `sas` are recognised as secret parameter
|
|
34
|
+
names.** Checked the token set against the parameters actually used by AWS
|
|
35
|
+
SigV4, Azure SAS, Google Cloud and OAuth 2: the signature and credential
|
|
36
|
+
parameters were already covered, but those four were not. `code`, `state`,
|
|
37
|
+
`nonce` and `client_id` are deliberately still ignored — an OAuth code is a
|
|
38
|
+
secret, but the name is far more often a country code, an HTTP status or a
|
|
39
|
+
discount code, and redacting those would destroy more than it protects.
|
|
40
|
+
- **Three ways a URL could still reach a message, all closed.** The redaction
|
|
41
|
+
boundary scrubbed the message and a handful of named fields, but 18 classes
|
|
42
|
+
interpolate some *other* attribute into `__str__` — a field, a column, a
|
|
43
|
+
resource — and a caller can put a URL in any of them. Every stored string is
|
|
44
|
+
now swept, so no class leaks a credential-bearing URL through its message,
|
|
45
|
+
its attributes or its `args`. A test fills every string argument of every
|
|
46
|
+
class with one and checks all three surfaces.
|
|
47
|
+
- Two classes assigned their attributes *after* calling `super().__init__`, so
|
|
48
|
+
the sweep never saw them. Both now assign first, and a test fails if any
|
|
49
|
+
constructor does it again.
|
|
50
|
+
- The URL pattern carried a `` anchor, so a URL directly following a word
|
|
51
|
+
character was never matched — including `feature_https://...`, the step name
|
|
52
|
+
`FeaturePreprocessingError` builds from its own argument.
|
|
53
|
+
|
|
54
|
+
## [0.4.1] - 2026-08-25
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- Tests that derive the facts stated in more than one file, instead of trusting
|
|
59
|
+
them to be copied correctly: the citation, security policy, changelog and
|
|
60
|
+
checklist must all name the current version, checklist sections must be
|
|
61
|
+
numbered uniquely, no document may quote a test count, and the supported
|
|
62
|
+
Python range must agree across `requires-python`, the classifiers, the CI
|
|
63
|
+
matrix and the release gate.
|
|
64
|
+
- A contract-coverage test. The whole-hierarchy suites called `pytest.skip()`
|
|
65
|
+
when they could not build a class, so a class could sit outside the pickling,
|
|
66
|
+
message and inheritance guarantees with CI still green — and two were doing
|
|
67
|
+
exactly that. Skipping now requires an explicit, reasoned entry in an
|
|
68
|
+
exclusion table, which is empty.
|
|
69
|
+
|
|
70
|
+
- **Python 3.14 support.** `requires-python` capped at `<3.14`, so the package
|
|
71
|
+
refused to install on the current stable interpreter — 3.14 was released in
|
|
72
|
+
October 2025 and is no longer upcoming. The range is now `>=3.10,<3.15`, 3.14
|
|
73
|
+
is in the classifiers and the CI matrix, and `test (3.14)` joins the checks
|
|
74
|
+
the release workflow requires before it will build.
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
|
|
78
|
+
- `SECURITY.md` states the boundary precisely, including what is **not**
|
|
79
|
+
covered: a bare non-URL secret written into free-form text cannot be
|
|
80
|
+
recognised. The previous wording claimed credentials "never appear ...
|
|
81
|
+
whatever you pass in", which was broader than the implementation.
|
|
82
|
+
|
|
83
|
+
- The serialization and hierarchy guarantees are stated more precisely.
|
|
84
|
+
"Survives a process boundary intact" now says what happens to state that
|
|
85
|
+
cannot be serialized, and "catches anything this package raises" is now
|
|
86
|
+
"every operational exception" — constructors deliberately raise plain
|
|
87
|
+
`TypeError` for invalid arguments, which sits outside the hierarchy.
|
|
88
|
+
|
|
89
|
+
### Fixed
|
|
90
|
+
|
|
91
|
+
- **An exception pickled by 0.4.0 could not be loaded by 0.4.1.** Restoring the
|
|
92
|
+
chain added three parameters to the private `_rebuild`, and an older payload
|
|
93
|
+
passes only three arguments — so a queued exception that outlived an upgrade,
|
|
94
|
+
or one sent by a worker on the previous release, raised `TypeError` on
|
|
95
|
+
unpickling. The new parameters carry defaults.
|
|
96
|
+
|
|
97
|
+
- The test probe fed a bare string to `Sequence[str]` parameters, so
|
|
98
|
+
`DataFormatError` and `DtypeMismatchError` correctly rejected it and were
|
|
99
|
+
silently skipped. The probe builds them properly now; nothing is skipped.
|
|
100
|
+
- `is_number` accepted booleans, because `bool` subclasses `int` and so
|
|
101
|
+
satisfies `numbers.Real`. A boolean is never a meaningful metric, threshold
|
|
102
|
+
or ratio, and accepting one hid a caller passing the wrong variable.
|
|
103
|
+
- `MergeKeyError("id", "cust_id")` silently became `['i', 'd']` — a bare string
|
|
104
|
+
is a sequence of strings. It now raises `TypeError`, matching
|
|
105
|
+
`DtypeMismatchError`, which already rejected this.
|
|
106
|
+
- `SECURITY.md` named 0.3.x as supported at 0.4.0, `CHECKLIST.md` was dated to
|
|
107
|
+
the previous release and numbered two sections 12, and the README quoted a
|
|
108
|
+
test count that no longer matched. `scripts/bump_version.py` now writes every
|
|
109
|
+
file that states the version, so they cannot drift apart by hand again.
|
|
110
|
+
- `black`'s target version is pinned rather than inferred from
|
|
111
|
+
`requires-python`. Adding 3.14 made inference pick `py314`, and black then
|
|
112
|
+
refused to verify its own output when run on an older interpreter — which is
|
|
113
|
+
what CI does.
|
|
114
|
+
|
|
115
|
+
- **Exception chaining was lost on a pickle round trip.** `__reduce__` saved
|
|
116
|
+
`args` and `__dict__`, but `__cause__`, `__context__` and
|
|
117
|
+
`__suppress_context__` are special exception state rather than `__dict__`
|
|
118
|
+
entries — so a wrapped exception rebuilt in another process no longer showed
|
|
119
|
+
what actually failed. All three are now restored explicitly. The 0.4.0 tests
|
|
120
|
+
did not catch this because they compared `args`, rendered text and `__dict__`
|
|
121
|
+
and never the chain; they now check it.
|
|
122
|
+
- **An exception carrying unpickleable state could not cross a process
|
|
123
|
+
boundary at all.** Several classes accept arbitrary caller state, so a
|
|
124
|
+
lambda, generator, open file or locally defined class made the whole
|
|
125
|
+
exception unserializable — replacing the real failure with a serialization
|
|
126
|
+
error about it. Such values are now replaced by an `UnpicklableValue`
|
|
127
|
+
describing what was there, and an unpickleable cause by an
|
|
128
|
+
`UnpicklableCause`, so the exception still arrives.
|
|
129
|
+
|
|
130
|
+
### Security
|
|
131
|
+
|
|
132
|
+
- **Redaction is no longer defeated by the traceback.** `log_exception` passed
|
|
133
|
+
`exc_info`, so logging rendered the whole exception chain — including a
|
|
134
|
+
wrapped third-party exception whose own message still quoted the
|
|
135
|
+
credential-bearing URL. Redacting what DataExcept renders did nothing about
|
|
136
|
+
that. When the chain contains a URL, `log_exception` now formats the
|
|
137
|
+
traceback and scrubs it; every other exception keeps the structured
|
|
138
|
+
`exc_info` path, so nothing changes for them.
|
|
139
|
+
- `SECURITY.md` documents what remains outside that boundary: the wrapped
|
|
140
|
+
exception object is still reachable, so `traceback.print_exc()`,
|
|
141
|
+
`repr(exc.__dict__)` or `logger.error(..., exc_info=True)` will render its
|
|
142
|
+
text. A third-party exception's message is not ours to rewrite.
|
|
143
|
+
|
|
144
|
+
- **Every GitHub Action is pinned to a full-length commit SHA.** All 34
|
|
145
|
+
references used mutable tags, including `pypa/gh-action-pypi-publish` in the
|
|
146
|
+
OIDC publishing job — the step that holds the credential which uploads to
|
|
147
|
+
PyPI. Whoever controls an action repository can move a tag to different code
|
|
148
|
+
at any time; a commit SHA is the only immutable reference. Each pin carries
|
|
149
|
+
the version in a trailing comment so it stays reviewable and Dependabot can
|
|
150
|
+
still update it, and a test fails if any mutable reference reappears.
|
|
151
|
+
|
|
152
|
+
- **Credentials in a URL path are now redacted.** `redact_url` kept the whole
|
|
153
|
+
path, so `WebhookError` logged a Slack webhook URL — which Slack documents as
|
|
154
|
+
a secret in its entirety — unchanged. Where the path *is* the credential the
|
|
155
|
+
path is now dropped, keeping the host, which is what makes the error
|
|
156
|
+
actionable.
|
|
157
|
+
- **Sensitive parameters are matched by substring, not an exact allowlist.**
|
|
158
|
+
`X-Amz-Signature`, `X-Amz-Credential`, `auth_token` and `refresh_token` all
|
|
159
|
+
passed through before. Fragments are covered too, so an OAuth
|
|
160
|
+
`#access_token=` no longer survives.
|
|
161
|
+
- **A secret can no longer be reintroduced after redaction.** Redacting only
|
|
162
|
+
the structured argument left two open routes: a caller-supplied `message`,
|
|
163
|
+
and the text of a wrapped exception quoting the original URL. Every message
|
|
164
|
+
in the hierarchy now passes through one scrubbing boundary, so a URL is
|
|
165
|
+
redacted wherever it appears. Where the library was handed the secret
|
|
166
|
+
explicitly, that exact value is removed from the message as well.
|
|
167
|
+
- **URL-bearing fields beyond the four patched classes are redacted.**
|
|
168
|
+
`DataLoadingError.source` documents itself as "file path, URL" and rendered
|
|
169
|
+
a presigned S3 URL verbatim. Nine such fields now use `redact_if_url`, which
|
|
170
|
+
leaves ordinary file paths untouched.
|
|
171
|
+
|
|
10
172
|
## [0.4.0] - 2026-08-24
|
|
11
173
|
|
|
12
174
|
### Added
|
|
@@ -236,7 +398,6 @@ project description, and its quick-start example did not run.
|
|
|
236
398
|
- `examples/example_usage.py` raised `TimeoutError` with keyword arguments the
|
|
237
399
|
builtin does not accept — a live instance of the shadowing hazard.
|
|
238
400
|
|
|
239
|
-
|
|
240
401
|
## [0.1.0] - 2026-08-24
|
|
241
402
|
|
|
242
403
|
First public release.
|
|
@@ -268,7 +429,9 @@ First public release.
|
|
|
268
429
|
- Published to PyPI via OIDC trusted publishing; no long-lived API token is
|
|
269
430
|
involved in a release.
|
|
270
431
|
|
|
271
|
-
[Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.
|
|
432
|
+
[Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.2...HEAD
|
|
433
|
+
[0.4.2]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.1...v0.4.2
|
|
434
|
+
[0.4.1]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.0...v0.4.1
|
|
272
435
|
[0.4.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.3.0...v0.4.0
|
|
273
436
|
[0.3.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.2.1...v0.3.0
|
|
274
437
|
[0.2.1]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.2.0...v0.2.1
|
|
@@ -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: "0.4.
|
|
4
|
+
version: "0.4.2"
|
|
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-08-
|
|
14
|
+
date-released: "2026-08-25"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: DataExcept
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: A Python package providing structured, easily-extendable custom exception types.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -9,13 +9,14 @@ Author: Diogo Ribeiro
|
|
|
9
9
|
Author-email: dfr@esmad.ipp.pt
|
|
10
10
|
Maintainer: Diogo Ribeiro
|
|
11
11
|
Maintainer-email: diogo.debastos.ribeiro@gmail.com
|
|
12
|
-
Requires-Python: >=3.10,<3.
|
|
12
|
+
Requires-Python: >=3.10,<3.15
|
|
13
13
|
Classifier: Programming Language :: Python :: 3
|
|
14
14
|
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
15
|
Classifier: Programming Language :: Python :: 3.10
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.11
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.12
|
|
18
18
|
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
20
|
Requires-Dist: tomli ; python_version < "3.11"
|
|
20
21
|
Project-URL: Changelog, https://github.com/DiogoRibeiro7/DataExcept/releases
|
|
21
22
|
Project-URL: Documentation, https://diogoribeiro7.github.io/DataExcept/
|
|
@@ -41,13 +42,13 @@ Description-Content-Type: text/markdown
|
|
|
41
42
|
|
|
42
43
|
## 🎯 Key Features
|
|
43
44
|
|
|
44
|
-
- **🏗️ Hierarchical Structure**: Catch one specific error, a whole domain, or
|
|
45
|
+
- **🏗️ Hierarchical Structure**: Catch one specific error, a whole domain, or every operational error via `DataExceptError`
|
|
45
46
|
- **📦 One Import**: Every exception is available from `dataexcept` directly, or from its domain module — same objects either way
|
|
46
|
-
- **📊 Data Science Focused**:
|
|
47
|
-
- **🔧 Production Ready**: Logging helpers, error context, and exceptions that
|
|
47
|
+
- **📊 Data Science Focused**: 100 exception classes covering ML pipelines, feature engineering, model training
|
|
48
|
+
- **🔧 Production Ready**: Logging helpers, error context, and exceptions that pickle — so they cross a process boundary with their message, attributes and cause intact
|
|
48
49
|
- **📚 Academic Quality**: Proper documentation, type hints, and citation support
|
|
49
|
-
- **🐍 Python 3.10
|
|
50
|
-
- **🧪 Well Tested**:
|
|
50
|
+
- **🐍 Python 3.10 – 3.14**: Every supported version tested in CI, with full type safety
|
|
51
|
+
- **🧪 Well Tested**: Full branch coverage of the package gated in CI, with contract tests over every exception class
|
|
51
52
|
|
|
52
53
|
## 📦 Quick Installation
|
|
53
54
|
|
|
@@ -213,7 +214,7 @@ except Exception as exc:
|
|
|
213
214
|
### Command Line Interface
|
|
214
215
|
|
|
215
216
|
```bash
|
|
216
|
-
# List every exception class the package exports (
|
|
217
|
+
# List every exception class the package exports (100 of them, alphabetically)
|
|
217
218
|
$ dataexcept list
|
|
218
219
|
ApiError
|
|
219
220
|
AuthenticationError
|
|
@@ -224,7 +225,7 @@ BiasDetectionError
|
|
|
224
225
|
|
|
225
226
|
# Check version
|
|
226
227
|
$ dataexcept --version
|
|
227
|
-
dataexcept 0.4.
|
|
228
|
+
dataexcept 0.4.2
|
|
228
229
|
```
|
|
229
230
|
|
|
230
231
|
## 🎯 Use Cases
|
|
@@ -374,7 +375,7 @@ If you use DataExcept in your research, please cite it:
|
|
|
374
375
|
author = {Ribeiro, Diogo},
|
|
375
376
|
title = {DataExcept: Structured Exception Handling for Data Science},
|
|
376
377
|
url = {https://github.com/DiogoRibeiro7/DataExcept},
|
|
377
|
-
version = {0.4.
|
|
378
|
+
version = {0.4.2},
|
|
378
379
|
year = {2026},
|
|
379
380
|
publisher = {GitHub}
|
|
380
381
|
}
|
|
@@ -15,13 +15,13 @@
|
|
|
15
15
|
|
|
16
16
|
## 🎯 Key Features
|
|
17
17
|
|
|
18
|
-
- **🏗️ Hierarchical Structure**: Catch one specific error, a whole domain, or
|
|
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
|
-
- **📊 Data Science Focused**:
|
|
21
|
-
- **🔧 Production Ready**: Logging helpers, error context, and exceptions that
|
|
20
|
+
- **📊 Data Science Focused**: 100 exception classes covering ML pipelines, feature engineering, model training
|
|
21
|
+
- **🔧 Production Ready**: Logging helpers, error context, and exceptions that pickle — so they cross a process boundary with their message, attributes and cause intact
|
|
22
22
|
- **📚 Academic Quality**: Proper documentation, type hints, and citation support
|
|
23
|
-
- **🐍 Python 3.10
|
|
24
|
-
- **🧪 Well Tested**:
|
|
23
|
+
- **🐍 Python 3.10 – 3.14**: Every supported version tested in CI, with full type safety
|
|
24
|
+
- **🧪 Well Tested**: Full branch coverage of the package gated in CI, with contract tests over every exception class
|
|
25
25
|
|
|
26
26
|
## 📦 Quick Installation
|
|
27
27
|
|
|
@@ -187,7 +187,7 @@ except Exception as exc:
|
|
|
187
187
|
### Command Line Interface
|
|
188
188
|
|
|
189
189
|
```bash
|
|
190
|
-
# List every exception class the package exports (
|
|
190
|
+
# List every exception class the package exports (100 of them, alphabetically)
|
|
191
191
|
$ dataexcept list
|
|
192
192
|
ApiError
|
|
193
193
|
AuthenticationError
|
|
@@ -198,7 +198,7 @@ BiasDetectionError
|
|
|
198
198
|
|
|
199
199
|
# Check version
|
|
200
200
|
$ dataexcept --version
|
|
201
|
-
dataexcept 0.4.
|
|
201
|
+
dataexcept 0.4.2
|
|
202
202
|
```
|
|
203
203
|
|
|
204
204
|
## 🎯 Use Cases
|
|
@@ -348,7 +348,7 @@ If you use DataExcept in your research, please cite it:
|
|
|
348
348
|
author = {Ribeiro, Diogo},
|
|
349
349
|
title = {DataExcept: Structured Exception Handling for Data Science},
|
|
350
350
|
url = {https://github.com/DiogoRibeiro7/DataExcept},
|
|
351
|
-
version = {0.4.
|
|
351
|
+
version = {0.4.2},
|
|
352
352
|
year = {2026},
|
|
353
353
|
publisher = {GitHub}
|
|
354
354
|
}
|
|
@@ -38,7 +38,7 @@ from . import ( # noqa: F401
|
|
|
38
38
|
security_exceptions,
|
|
39
39
|
)
|
|
40
40
|
from ._deprecation import resolve_deprecated
|
|
41
|
-
from .base import DataExceptError
|
|
41
|
+
from .base import DataExceptError, UnpicklableCause, UnpicklableValue
|
|
42
42
|
from .database_exceptions import (
|
|
43
43
|
DatabaseConnectionError,
|
|
44
44
|
DatabaseError,
|
|
@@ -163,8 +163,12 @@ from .security_exceptions import (
|
|
|
163
163
|
)
|
|
164
164
|
|
|
165
165
|
__all__ = [
|
|
166
|
-
# The root of the hierarchy: catches
|
|
166
|
+
# The root of the hierarchy: catches every operational exception the
|
|
167
|
+
# package raises.
|
|
167
168
|
"DataExceptError",
|
|
169
|
+
# Placeholders for state that could not survive serialization.
|
|
170
|
+
"UnpicklableCause",
|
|
171
|
+
"UnpicklableValue",
|
|
168
172
|
# Every exception class the package defines.
|
|
169
173
|
"ApiError",
|
|
170
174
|
"AuthenticationError",
|
|
@@ -15,8 +15,14 @@ def is_number(value: object) -> bool:
|
|
|
15
15
|
science. ``numbers.Real`` accepts them because NumPy registers its scalar
|
|
16
16
|
types with the ABC, and it needs no dependency on NumPy to do so.
|
|
17
17
|
|
|
18
|
+
Booleans are excluded: ``bool`` subclasses ``int``, so ``numbers.Real``
|
|
19
|
+
would accept ``True`` as a metric.
|
|
20
|
+
|
|
18
21
|
This is a function rather than an inline ``isinstance`` because narrowing a
|
|
19
22
|
value to ``numbers.Real`` defeats mypy's inference for the rest of the
|
|
20
23
|
enclosing class.
|
|
21
24
|
"""
|
|
22
|
-
|
|
25
|
+
# bool subclasses int, so numbers.Real accepts True and False. A boolean
|
|
26
|
+
# is never a meaningful metric, threshold or ratio, and accepting one hides
|
|
27
|
+
# a caller passing the wrong variable.
|
|
28
|
+
return not isinstance(value, bool) and isinstance(value, numbers.Real)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""The root of the DataExcept exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Every operational exception this package defines derives from
|
|
4
|
+
:class:`DataExceptError`, so a caller can catch the whole library with one
|
|
5
|
+
clause while still catching narrowly where it matters::
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
run_pipeline()
|
|
9
|
+
except ValidationError:
|
|
10
|
+
... # exactly this failure
|
|
11
|
+
except DataExceptError:
|
|
12
|
+
... # anything else DataExcept raised
|
|
13
|
+
|
|
14
|
+
(Constructors also raise plain ``TypeError`` when given invalid arguments.
|
|
15
|
+
Those are programming errors, not operational ones, and are deliberately not
|
|
16
|
+
part of this hierarchy.)
|
|
17
|
+
|
|
18
|
+
The base also carries the serialization contract for the hierarchy. Two
|
|
19
|
+
problems make that necessary:
|
|
20
|
+
|
|
21
|
+
* Most constructors take several arguments while ``Exception.args`` holds only
|
|
22
|
+
the rendered message, so the default protocol -- which replays ``args``
|
|
23
|
+
through ``__init__`` -- cannot rebuild them.
|
|
24
|
+
* Several exceptions accept arbitrary caller state (``DataValidationError``
|
|
25
|
+
takes any ``value``), and that state may not be pickleable at all.
|
|
26
|
+
|
|
27
|
+
An exception that cannot cross a process boundary is useless exactly where a
|
|
28
|
+
data pipeline needs it most, so rather than fail, unpickleable state is
|
|
29
|
+
replaced by a description of what was there.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import pickle
|
|
35
|
+
from typing import Any, Dict, Optional, Tuple, Type
|
|
36
|
+
|
|
37
|
+
from .redaction import redact_urls_in_text
|
|
38
|
+
|
|
39
|
+
__all__ = ["DataExceptError", "UnpicklableCause", "UnpicklableValue"]
|
|
40
|
+
|
|
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
|
+
_CAUSE_ATTRIBUTES = ("original", "original_exception", "cause")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
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
|
+
"""
|
|
53
|
+
|
|
54
|
+
__slots__ = ("description",)
|
|
55
|
+
|
|
56
|
+
def __init__(self, description: str) -> None:
|
|
57
|
+
self.description = description
|
|
58
|
+
|
|
59
|
+
def __repr__(self) -> str:
|
|
60
|
+
return f"<unpicklable: {self.description}>"
|
|
61
|
+
|
|
62
|
+
def __str__(self) -> str:
|
|
63
|
+
return self.__repr__()
|
|
64
|
+
|
|
65
|
+
def __eq__(self, other: object) -> bool:
|
|
66
|
+
return (
|
|
67
|
+
isinstance(other, UnpicklableValue)
|
|
68
|
+
and other.description == self.description
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def __hash__(self) -> int:
|
|
72
|
+
return hash(self.description)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _safe(value: Any) -> Any:
|
|
76
|
+
"""Return *value*, or a placeholder if it cannot be pickled."""
|
|
77
|
+
try:
|
|
78
|
+
pickle.dumps(value)
|
|
79
|
+
except Exception:
|
|
80
|
+
try:
|
|
81
|
+
description = f"{type(value).__name__}: {value!r}"
|
|
82
|
+
except Exception: # pragma: no cover - a repr that itself raises
|
|
83
|
+
description = type(value).__name__
|
|
84
|
+
return UnpicklableValue(description[:200])
|
|
85
|
+
return value
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _safe_exception(exc: Optional[BaseException]) -> Optional[BaseException]:
|
|
89
|
+
"""Return *exc*, or an exception describing it if it cannot be pickled."""
|
|
90
|
+
if exc is None:
|
|
91
|
+
return None
|
|
92
|
+
try:
|
|
93
|
+
pickle.dumps(exc)
|
|
94
|
+
except Exception:
|
|
95
|
+
return UnpicklableCause(f"{type(exc).__name__}: {exc}")
|
|
96
|
+
return exc
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _rebuild(
|
|
100
|
+
cls: Type["DataExceptError"],
|
|
101
|
+
args: Tuple[Any, ...],
|
|
102
|
+
state: Dict[str, Any],
|
|
103
|
+
cause: Optional[BaseException] = None,
|
|
104
|
+
context: Optional[BaseException] = None,
|
|
105
|
+
suppress_context: bool = False,
|
|
106
|
+
) -> "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
|
+
"""
|
|
123
|
+
exc = cls.__new__(cls)
|
|
124
|
+
Exception.__init__(exc, *args)
|
|
125
|
+
exc.__dict__.update(state)
|
|
126
|
+
exc.__cause__ = cause
|
|
127
|
+
exc.__context__ = context
|
|
128
|
+
exc.__suppress_context__ = suppress_context
|
|
129
|
+
return exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DataExceptError(Exception):
|
|
133
|
+
"""Base class for every operational exception DataExcept raises."""
|
|
134
|
+
|
|
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
|
+
_keep_url_path = True
|
|
139
|
+
|
|
140
|
+
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
|
+
keep_path = type(self)._keep_url_path
|
|
146
|
+
if args and isinstance(args[0], str):
|
|
147
|
+
args = (redact_urls_in_text(args[0], keep_path=keep_path),) + args[1:]
|
|
148
|
+
|
|
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
|
+
for name, value in list(self.__dict__.items()):
|
|
159
|
+
if isinstance(value, str) and "://" in value:
|
|
160
|
+
self.__dict__[name] = redact_urls_in_text(value, keep_path=keep_path)
|
|
161
|
+
|
|
162
|
+
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
|
+
for attribute in _CAUSE_ATTRIBUTES:
|
|
168
|
+
candidate = getattr(self, attribute, None)
|
|
169
|
+
if isinstance(candidate, BaseException):
|
|
170
|
+
self.__cause__ = candidate
|
|
171
|
+
break
|
|
172
|
+
|
|
173
|
+
def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]:
|
|
174
|
+
args = tuple(_safe(arg) for arg in self.args)
|
|
175
|
+
state = {key: _safe(value) for key, value in self.__dict__.items()}
|
|
176
|
+
return (
|
|
177
|
+
_rebuild,
|
|
178
|
+
(
|
|
179
|
+
type(self),
|
|
180
|
+
args,
|
|
181
|
+
state,
|
|
182
|
+
_safe_exception(self.__cause__),
|
|
183
|
+
_safe_exception(self.__context__),
|
|
184
|
+
self.__suppress_context__,
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class UnpicklableCause(DataExceptError):
|
|
190
|
+
"""Stands in for a cause that could not be serialized.
|
|
191
|
+
|
|
192
|
+
``__cause__`` and ``__context__`` must be exceptions, so the placeholder
|
|
193
|
+
used for ordinary attributes will not do here. Dropping the chain instead
|
|
194
|
+
would silently lose the reason for the failure.
|
|
195
|
+
"""
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
7
|
from .base import DataExceptError
|
|
8
|
+
from .redaction import redact_if_url
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class DataEngineeringError(DataExceptError):
|
|
@@ -111,7 +112,7 @@ class MissingPartitionError(DataEngineeringError):
|
|
|
111
112
|
message: Optional custom error message.
|
|
112
113
|
"""
|
|
113
114
|
self.partition = partition
|
|
114
|
-
self.location = location
|
|
115
|
+
self.location = redact_if_url(location)
|
|
115
116
|
default = f"Partition '{partition}' not found at {location}"
|
|
116
117
|
super().__init__(message or default)
|
|
117
118
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Any, Optional, Sequence
|
|
6
6
|
|
|
7
7
|
from .._validation import is_number
|
|
8
|
+
from ..redaction import redact_if_url
|
|
8
9
|
from .base import DataScienceError
|
|
9
10
|
|
|
10
11
|
|
|
@@ -26,7 +27,7 @@ class DataLoadingError(DataScienceError):
|
|
|
26
27
|
)
|
|
27
28
|
|
|
28
29
|
message = f"Failed to load data from {source!r}: {original}"
|
|
29
|
-
self.source = source
|
|
30
|
+
self.source = redact_if_url(source)
|
|
30
31
|
self.original = original
|
|
31
32
|
super().__init__(message)
|
|
32
33
|
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Any, Optional
|
|
6
6
|
|
|
7
7
|
from .._validation import is_number
|
|
8
|
+
from ..redaction import redact_if_url
|
|
8
9
|
from .base import DataScienceError
|
|
9
10
|
|
|
10
11
|
|
|
@@ -26,7 +27,7 @@ class ModelSerializationError(DataScienceError):
|
|
|
26
27
|
)
|
|
27
28
|
|
|
28
29
|
message = f"Failed to serialize to {path!r}: {original}"
|
|
29
|
-
self.path = path
|
|
30
|
+
self.path = redact_if_url(path)
|
|
30
31
|
self.original = original
|
|
31
32
|
super().__init__(message)
|
|
32
33
|
|
|
@@ -69,8 +69,10 @@ class ConvergenceError(ModelTrainingError):
|
|
|
69
69
|
f"Model '{model_type}' failed to converge after "
|
|
70
70
|
f"{iterations} iterations"
|
|
71
71
|
)
|
|
72
|
-
super().__init__
|
|
72
|
+
# Assigned before super(): DataExceptError.__init__ sweeps the stored
|
|
73
|
+
# strings for URLs, and anything set afterwards escapes that.
|
|
73
74
|
self.iterations = iterations
|
|
75
|
+
super().__init__(model_type=model_type, epoch=None, message=message)
|
|
74
76
|
|
|
75
77
|
def __str__(self) -> str:
|
|
76
78
|
return f"[ConvergenceError] {self.message}"
|
|
@@ -41,9 +41,14 @@ class EmailError(NotificationError):
|
|
|
41
41
|
class WebhookError(NotificationError):
|
|
42
42
|
"""Raised when a webhook POST fails."""
|
|
43
43
|
|
|
44
|
+
# Slack, Discord and others put the secret in the webhook path, so keeping
|
|
45
|
+
# the path would defeat the redaction. This also applies to the scrubbing
|
|
46
|
+
# of the whole message, which is how a wrapped HTTP exception quoting the
|
|
47
|
+
# original URL gets its path dropped too.
|
|
48
|
+
_keep_url_path = False
|
|
49
|
+
|
|
44
50
|
def __init__(self, url: str, original_exception: Exception | None = None):
|
|
45
|
-
|
|
46
|
-
self.url = redact_url(url)
|
|
51
|
+
self.url = redact_url(url, keep_path=False)
|
|
47
52
|
# original_exception is set by NotificationError.__init__ below.
|
|
48
53
|
msg = f"Webhook to URL '{self.url}' failed"
|
|
49
54
|
if original_exception:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from .base import DataExceptError
|
|
6
|
+
from .redaction import redact_if_url
|
|
6
7
|
|
|
7
8
|
|
|
8
9
|
class CustomIOError(DataExceptError):
|
|
@@ -21,7 +22,7 @@ class FileReadError(CustomIOError):
|
|
|
21
22
|
path: File path that could not be read.
|
|
22
23
|
original: Optional underlying exception.
|
|
23
24
|
"""
|
|
24
|
-
self.path = path
|
|
25
|
+
self.path = redact_if_url(path)
|
|
25
26
|
self.original = original
|
|
26
27
|
msg = f"Failed to read file '{path}'"
|
|
27
28
|
if original:
|
|
@@ -39,7 +40,7 @@ class FileWriteError(CustomIOError):
|
|
|
39
40
|
path: File path that could not be written to.
|
|
40
41
|
original: Optional underlying exception.
|
|
41
42
|
"""
|
|
42
|
-
self.path = path
|
|
43
|
+
self.path = redact_if_url(path)
|
|
43
44
|
self.original = original
|
|
44
45
|
msg = f"Failed to write file '{path}'"
|
|
45
46
|
if original:
|
|
@@ -56,7 +57,7 @@ class FileLockError(CustomIOError):
|
|
|
56
57
|
Args:
|
|
57
58
|
path: Path of the lock file.
|
|
58
59
|
"""
|
|
59
|
-
self.path = path
|
|
60
|
+
self.path = redact_if_url(path)
|
|
60
61
|
super().__init__(f"Unable to obtain lock for '{path}'")
|
|
61
62
|
|
|
62
63
|
|