DataExcept 0.4.0__tar.gz → 0.4.1__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.1}/CHANGELOG.md +120 -2
- {dataexcept-0.4.0 → dataexcept-0.4.1}/CITATION.cff +2 -2
- {dataexcept-0.4.0 → dataexcept-0.4.1}/PKG-INFO +11 -10
- {dataexcept-0.4.0 → dataexcept-0.4.1}/README.md +8 -8
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/__init__.py +6 -2
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/_validation.py +7 -1
- dataexcept-0.4.1/dataexcept/base.py +190 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/dataengineering_exceptions.py +2 -1
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/datascience_exceptions/ingestion.py +2 -1
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/datascience_exceptions/operations.py +2 -1
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/notification.py +7 -2
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/io_exceptions.py +4 -3
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/logging_helpers.py +41 -1
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/pandas_exceptions.py +7 -5
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/pipeline_exceptions.py +3 -3
- dataexcept-0.4.1/dataexcept/redaction.py +201 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/security_exceptions.py +4 -2
- {dataexcept-0.4.0 → dataexcept-0.4.1}/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.1}/LICENSE +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/__main__.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/_deprecation.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/database_exceptions.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/datascience_exceptions/__init__.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/datascience_exceptions/base.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/datascience_exceptions/training.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/__init__.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/authentication.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/base.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/configuration.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/external.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/lifecycle.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/parsing.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/scheduling.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/validation.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/job_exceptions.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/network_exceptions.py +0 -0
- {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/py.typed +0 -0
|
@@ -7,6 +7,124 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.1] - 2026-08-25
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Tests that derive the facts stated in more than one file, instead of trusting
|
|
15
|
+
them to be copied correctly: the citation, security policy, changelog and
|
|
16
|
+
checklist must all name the current version, checklist sections must be
|
|
17
|
+
numbered uniquely, no document may quote a test count, and the supported
|
|
18
|
+
Python range must agree across `requires-python`, the classifiers, the CI
|
|
19
|
+
matrix and the release gate.
|
|
20
|
+
- A contract-coverage test. The whole-hierarchy suites called `pytest.skip()`
|
|
21
|
+
when they could not build a class, so a class could sit outside the pickling,
|
|
22
|
+
message and inheritance guarantees with CI still green — and two were doing
|
|
23
|
+
exactly that. Skipping now requires an explicit, reasoned entry in an
|
|
24
|
+
exclusion table, which is empty.
|
|
25
|
+
|
|
26
|
+
- **Python 3.14 support.** `requires-python` capped at `<3.14`, so the package
|
|
27
|
+
refused to install on the current stable interpreter — 3.14 was released in
|
|
28
|
+
October 2025 and is no longer upcoming. The range is now `>=3.10,<3.15`, 3.14
|
|
29
|
+
is in the classifiers and the CI matrix, and `test (3.14)` joins the checks
|
|
30
|
+
the release workflow requires before it will build.
|
|
31
|
+
|
|
32
|
+
### Changed
|
|
33
|
+
|
|
34
|
+
- `SECURITY.md` states the boundary precisely, including what is **not**
|
|
35
|
+
covered: a bare non-URL secret written into free-form text cannot be
|
|
36
|
+
recognised. The previous wording claimed credentials "never appear ...
|
|
37
|
+
whatever you pass in", which was broader than the implementation.
|
|
38
|
+
|
|
39
|
+
- The serialization and hierarchy guarantees are stated more precisely.
|
|
40
|
+
"Survives a process boundary intact" now says what happens to state that
|
|
41
|
+
cannot be serialized, and "catches anything this package raises" is now
|
|
42
|
+
"every operational exception" — constructors deliberately raise plain
|
|
43
|
+
`TypeError` for invalid arguments, which sits outside the hierarchy.
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
- **An exception pickled by 0.4.0 could not be loaded by 0.4.1.** Restoring the
|
|
48
|
+
chain added three parameters to the private `_rebuild`, and an older payload
|
|
49
|
+
passes only three arguments — so a queued exception that outlived an upgrade,
|
|
50
|
+
or one sent by a worker on the previous release, raised `TypeError` on
|
|
51
|
+
unpickling. The new parameters carry defaults.
|
|
52
|
+
|
|
53
|
+
- The test probe fed a bare string to `Sequence[str]` parameters, so
|
|
54
|
+
`DataFormatError` and `DtypeMismatchError` correctly rejected it and were
|
|
55
|
+
silently skipped. The probe builds them properly now; nothing is skipped.
|
|
56
|
+
- `is_number` accepted booleans, because `bool` subclasses `int` and so
|
|
57
|
+
satisfies `numbers.Real`. A boolean is never a meaningful metric, threshold
|
|
58
|
+
or ratio, and accepting one hid a caller passing the wrong variable.
|
|
59
|
+
- `MergeKeyError("id", "cust_id")` silently became `['i', 'd']` — a bare string
|
|
60
|
+
is a sequence of strings. It now raises `TypeError`, matching
|
|
61
|
+
`DtypeMismatchError`, which already rejected this.
|
|
62
|
+
- `SECURITY.md` named 0.3.x as supported at 0.4.0, `CHECKLIST.md` was dated to
|
|
63
|
+
the previous release and numbered two sections 12, and the README quoted a
|
|
64
|
+
test count that no longer matched. `scripts/bump_version.py` now writes every
|
|
65
|
+
file that states the version, so they cannot drift apart by hand again.
|
|
66
|
+
- `black`'s target version is pinned rather than inferred from
|
|
67
|
+
`requires-python`. Adding 3.14 made inference pick `py314`, and black then
|
|
68
|
+
refused to verify its own output when run on an older interpreter — which is
|
|
69
|
+
what CI does.
|
|
70
|
+
|
|
71
|
+
- **Exception chaining was lost on a pickle round trip.** `__reduce__` saved
|
|
72
|
+
`args` and `__dict__`, but `__cause__`, `__context__` and
|
|
73
|
+
`__suppress_context__` are special exception state rather than `__dict__`
|
|
74
|
+
entries — so a wrapped exception rebuilt in another process no longer showed
|
|
75
|
+
what actually failed. All three are now restored explicitly. The 0.4.0 tests
|
|
76
|
+
did not catch this because they compared `args`, rendered text and `__dict__`
|
|
77
|
+
and never the chain; they now check it.
|
|
78
|
+
- **An exception carrying unpickleable state could not cross a process
|
|
79
|
+
boundary at all.** Several classes accept arbitrary caller state, so a
|
|
80
|
+
lambda, generator, open file or locally defined class made the whole
|
|
81
|
+
exception unserializable — replacing the real failure with a serialization
|
|
82
|
+
error about it. Such values are now replaced by an `UnpicklableValue`
|
|
83
|
+
describing what was there, and an unpickleable cause by an
|
|
84
|
+
`UnpicklableCause`, so the exception still arrives.
|
|
85
|
+
|
|
86
|
+
### Security
|
|
87
|
+
|
|
88
|
+
- **Redaction is no longer defeated by the traceback.** `log_exception` passed
|
|
89
|
+
`exc_info`, so logging rendered the whole exception chain — including a
|
|
90
|
+
wrapped third-party exception whose own message still quoted the
|
|
91
|
+
credential-bearing URL. Redacting what DataExcept renders did nothing about
|
|
92
|
+
that. When the chain contains a URL, `log_exception` now formats the
|
|
93
|
+
traceback and scrubs it; every other exception keeps the structured
|
|
94
|
+
`exc_info` path, so nothing changes for them.
|
|
95
|
+
- `SECURITY.md` documents what remains outside that boundary: the wrapped
|
|
96
|
+
exception object is still reachable, so `traceback.print_exc()`,
|
|
97
|
+
`repr(exc.__dict__)` or `logger.error(..., exc_info=True)` will render its
|
|
98
|
+
text. A third-party exception's message is not ours to rewrite.
|
|
99
|
+
|
|
100
|
+
- **Every GitHub Action is pinned to a full-length commit SHA.** All 34
|
|
101
|
+
references used mutable tags, including `pypa/gh-action-pypi-publish` in the
|
|
102
|
+
OIDC publishing job — the step that holds the credential which uploads to
|
|
103
|
+
PyPI. Whoever controls an action repository can move a tag to different code
|
|
104
|
+
at any time; a commit SHA is the only immutable reference. Each pin carries
|
|
105
|
+
the version in a trailing comment so it stays reviewable and Dependabot can
|
|
106
|
+
still update it, and a test fails if any mutable reference reappears.
|
|
107
|
+
|
|
108
|
+
- **Credentials in a URL path are now redacted.** `redact_url` kept the whole
|
|
109
|
+
path, so `WebhookError` logged a Slack webhook URL — which Slack documents as
|
|
110
|
+
a secret in its entirety — unchanged. Where the path *is* the credential the
|
|
111
|
+
path is now dropped, keeping the host, which is what makes the error
|
|
112
|
+
actionable.
|
|
113
|
+
- **Sensitive parameters are matched by substring, not an exact allowlist.**
|
|
114
|
+
`X-Amz-Signature`, `X-Amz-Credential`, `auth_token` and `refresh_token` all
|
|
115
|
+
passed through before. Fragments are covered too, so an OAuth
|
|
116
|
+
`#access_token=` no longer survives.
|
|
117
|
+
- **A secret can no longer be reintroduced after redaction.** Redacting only
|
|
118
|
+
the structured argument left two open routes: a caller-supplied `message`,
|
|
119
|
+
and the text of a wrapped exception quoting the original URL. Every message
|
|
120
|
+
in the hierarchy now passes through one scrubbing boundary, so a URL is
|
|
121
|
+
redacted wherever it appears. Where the library was handed the secret
|
|
122
|
+
explicitly, that exact value is removed from the message as well.
|
|
123
|
+
- **URL-bearing fields beyond the four patched classes are redacted.**
|
|
124
|
+
`DataLoadingError.source` documents itself as "file path, URL" and rendered
|
|
125
|
+
a presigned S3 URL verbatim. Nine such fields now use `redact_if_url`, which
|
|
126
|
+
leaves ordinary file paths untouched.
|
|
127
|
+
|
|
10
128
|
## [0.4.0] - 2026-08-24
|
|
11
129
|
|
|
12
130
|
### Added
|
|
@@ -236,7 +354,6 @@ project description, and its quick-start example did not run.
|
|
|
236
354
|
- `examples/example_usage.py` raised `TimeoutError` with keyword arguments the
|
|
237
355
|
builtin does not accept — a live instance of the shadowing hazard.
|
|
238
356
|
|
|
239
|
-
|
|
240
357
|
## [0.1.0] - 2026-08-24
|
|
241
358
|
|
|
242
359
|
First public release.
|
|
@@ -268,7 +385,8 @@ First public release.
|
|
|
268
385
|
- Published to PyPI via OIDC trusted publishing; no long-lived API token is
|
|
269
386
|
involved in a release.
|
|
270
387
|
|
|
271
|
-
[Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.
|
|
388
|
+
[Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.1...HEAD
|
|
389
|
+
[0.4.1]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.0...v0.4.1
|
|
272
390
|
[0.4.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.3.0...v0.4.0
|
|
273
391
|
[0.3.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.2.1...v0.3.0
|
|
274
392
|
[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.1"
|
|
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.1
|
|
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.1
|
|
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.1},
|
|
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.1
|
|
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.1},
|
|
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,190 @@
|
|
|
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 --
|
|
142
|
+
# a constructor, a caller-supplied `message`, or the text of a wrapped
|
|
143
|
+
# exception quoting the original URL -- it is scrubbed here. Redacting
|
|
144
|
+
# only the structured argument leaves all three of those 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
|
+
# Many classes also store the message on self.message and render *that*
|
|
149
|
+
# in __str__, so scrubbing args alone would leave the rendered form
|
|
150
|
+
# untouched. Subclasses set it before calling up, so it is here to fix.
|
|
151
|
+
stored = self.__dict__.get("message")
|
|
152
|
+
if isinstance(stored, str):
|
|
153
|
+
# Written straight into __dict__, symmetric with the read above:
|
|
154
|
+
# this rewrites state a subclass already stored, rather than the
|
|
155
|
+
# base declaring an attribute of its own.
|
|
156
|
+
self.__dict__["message"] = redact_urls_in_text(stored, keep_path=keep_path)
|
|
157
|
+
super().__init__(*args)
|
|
158
|
+
# Constructors that wrap another exception record it on an attribute.
|
|
159
|
+
# Mirroring it into __cause__ is what makes a traceback print the
|
|
160
|
+
# underlying failure, exactly as `raise ... from exc` would; assigning
|
|
161
|
+
# __cause__ also sets __suppress_context__, as `raise from` does.
|
|
162
|
+
for attribute in _CAUSE_ATTRIBUTES:
|
|
163
|
+
candidate = getattr(self, attribute, None)
|
|
164
|
+
if isinstance(candidate, BaseException):
|
|
165
|
+
self.__cause__ = candidate
|
|
166
|
+
break
|
|
167
|
+
|
|
168
|
+
def __reduce__(self) -> Tuple[Any, Tuple[Any, ...]]:
|
|
169
|
+
args = tuple(_safe(arg) for arg in self.args)
|
|
170
|
+
state = {key: _safe(value) for key, value in self.__dict__.items()}
|
|
171
|
+
return (
|
|
172
|
+
_rebuild,
|
|
173
|
+
(
|
|
174
|
+
type(self),
|
|
175
|
+
args,
|
|
176
|
+
state,
|
|
177
|
+
_safe_exception(self.__cause__),
|
|
178
|
+
_safe_exception(self.__context__),
|
|
179
|
+
self.__suppress_context__,
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class UnpicklableCause(DataExceptError):
|
|
185
|
+
"""Stands in for a cause that could not be serialized.
|
|
186
|
+
|
|
187
|
+
``__cause__`` and ``__context__`` must be exceptions, so the placeholder
|
|
188
|
+
used for ordinary attributes will not do here. Dropping the chain instead
|
|
189
|
+
would silently lose the reason for the failure.
|
|
190
|
+
"""
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -5,8 +5,11 @@ from __future__ import annotations
|
|
|
5
5
|
import contextlib
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
|
+
import traceback
|
|
8
9
|
from typing import Any, Iterator, Mapping, Optional
|
|
9
10
|
|
|
11
|
+
from .redaction import redact_urls_in_text
|
|
12
|
+
|
|
10
13
|
Context = Mapping[str, Any]
|
|
11
14
|
|
|
12
15
|
__all__ = [
|
|
@@ -37,6 +40,25 @@ def _build_extra(context: Context | None) -> dict[str, Any] | None:
|
|
|
37
40
|
return {"dataexcept_context": serialized}
|
|
38
41
|
|
|
39
42
|
|
|
43
|
+
def _chain_mentions_a_url(exc: BaseException) -> bool:
|
|
44
|
+
"""True if *exc* or anything it chains to renders a URL.
|
|
45
|
+
|
|
46
|
+
A cheap pre-check: walking the chain and testing for "://" avoids
|
|
47
|
+
formatting a traceback for every exception that is logged.
|
|
48
|
+
"""
|
|
49
|
+
seen: set[int] = set()
|
|
50
|
+
current: BaseException | None = exc
|
|
51
|
+
while current is not None and id(current) not in seen:
|
|
52
|
+
seen.add(id(current))
|
|
53
|
+
try:
|
|
54
|
+
if "://" in str(current):
|
|
55
|
+
return True
|
|
56
|
+
except Exception: # pragma: no cover - a __str__ that itself raises
|
|
57
|
+
return True
|
|
58
|
+
current = current.__cause__ or current.__context__
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
40
62
|
def log_exception(
|
|
41
63
|
exc: Exception,
|
|
42
64
|
logger: Optional[logging.Logger] = None,
|
|
@@ -46,11 +68,29 @@ def log_exception(
|
|
|
46
68
|
"""Log *exc* at the given log *level* using *logger*.
|
|
47
69
|
|
|
48
70
|
If *logger* is ``None`` a module level logger is used.
|
|
71
|
+
|
|
72
|
+
DataExcept redacts what it renders, but a wrapped third-party exception
|
|
73
|
+
renders itself: an HTTP client's error may quote the credential-bearing URL
|
|
74
|
+
it was called with, and ``exc_info`` makes logging print that whole chain.
|
|
75
|
+
When the chain contains a URL the traceback is formatted and scrubbed here;
|
|
76
|
+
otherwise the structured ``exc_info`` path is used unchanged, so ordinary
|
|
77
|
+
exceptions keep the shape log aggregators expect.
|
|
49
78
|
"""
|
|
50
79
|
if logger is None:
|
|
51
80
|
logger = logging.getLogger(__name__)
|
|
81
|
+
extra = _build_extra(context)
|
|
82
|
+
|
|
83
|
+
if _chain_mentions_a_url(exc):
|
|
84
|
+
formatted = "".join(
|
|
85
|
+
traceback.format_exception(type(exc), exc, exc.__traceback__)
|
|
86
|
+
)
|
|
87
|
+
keep_path = getattr(type(exc), "_keep_url_path", True)
|
|
88
|
+
scrubbed = redact_urls_in_text(formatted, keep_path=keep_path).rstrip()
|
|
89
|
+
logger.log(level, "%s\n%s", exc, scrubbed, extra=extra)
|
|
90
|
+
return
|
|
91
|
+
|
|
52
92
|
exc_info = (type(exc), exc, exc.__traceback__)
|
|
53
|
-
logger.log(level, "%s", exc, exc_info=exc_info, extra=
|
|
93
|
+
logger.log(level, "%s", exc, exc_info=exc_info, extra=extra)
|
|
54
94
|
|
|
55
95
|
|
|
56
96
|
@contextlib.contextmanager
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Optional, Sequence
|
|
6
6
|
|
|
7
7
|
from .base import DataExceptError
|
|
8
|
+
from .redaction import redact_if_url
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class PandasError(DataExceptError):
|
|
@@ -98,10 +99,11 @@ class MergeKeyError(PandasError):
|
|
|
98
99
|
"""
|
|
99
100
|
|
|
100
101
|
def __init__(self, left_keys: Sequence[str], right_keys: Sequence[str]) -> None:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
# A bare string is a sequence of strings, so "id" would silently become
|
|
103
|
+
# ['i', 'd']. Reject it, as DtypeMismatchError already does.
|
|
104
|
+
for name, keys in (("left_keys", left_keys), ("right_keys", right_keys)):
|
|
105
|
+
if isinstance(keys, str) or not all(isinstance(k, str) for k in keys):
|
|
106
|
+
raise TypeError(f"{name} must be a sequence of strings, not a string")
|
|
105
107
|
self.left_keys = list(left_keys)
|
|
106
108
|
self.right_keys = list(right_keys)
|
|
107
109
|
msg = f"Failed to merge on keys {self.left_keys} and {self.right_keys}"
|
|
@@ -126,7 +128,7 @@ class PandasIOError(PandasError):
|
|
|
126
128
|
raise TypeError(
|
|
127
129
|
f"original must be Exception, got {type(original).__name__}"
|
|
128
130
|
)
|
|
129
|
-
self.path = path
|
|
131
|
+
self.path = redact_if_url(path)
|
|
130
132
|
self.original = original
|
|
131
133
|
msg = f"Pandas I/O operation failed on {path!r}: {original}"
|
|
132
134
|
super().__init__(msg)
|
|
@@ -6,7 +6,7 @@ from typing import Any, Optional
|
|
|
6
6
|
|
|
7
7
|
from ._deprecation import resolve_deprecated
|
|
8
8
|
from .base import DataExceptError
|
|
9
|
-
from .redaction import redact_url
|
|
9
|
+
from .redaction import redact_if_url, redact_url
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
class PipelineError(DataExceptError):
|
|
@@ -45,7 +45,7 @@ class StorageError(PipelineError):
|
|
|
45
45
|
message: Optional[str] = None,
|
|
46
46
|
) -> None:
|
|
47
47
|
default = f"Storage {operation} failed at location: '{location}'."
|
|
48
|
-
self.location = location
|
|
48
|
+
self.location = redact_if_url(location)
|
|
49
49
|
self.operation = operation
|
|
50
50
|
super().__init__(message or default)
|
|
51
51
|
|
|
@@ -187,7 +187,7 @@ class DataFetchError(PipelineError):
|
|
|
187
187
|
message: Optional[str] = None,
|
|
188
188
|
) -> None:
|
|
189
189
|
default = f"Failed to fetch '{source}' data for cid={cid}"
|
|
190
|
-
self.source = source
|
|
190
|
+
self.source = redact_if_url(source)
|
|
191
191
|
self.cid = cid
|
|
192
192
|
super().__init__(message or default)
|
|
193
193
|
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Redaction helpers for values that must not reach a log.
|
|
2
|
+
|
|
3
|
+
Several exceptions here are raised with credentials in hand: an authentication
|
|
4
|
+
token, a database URL carrying a password, a webhook URL whose *path* is the
|
|
5
|
+
secret. Those values end up in the exception message, and
|
|
6
|
+
:func:`dataexcept.logging_helpers.log_exception` logs ``str(exc)``, so without
|
|
7
|
+
redaction a failed delivery writes the credential to the log.
|
|
8
|
+
|
|
9
|
+
The aim is to keep an error debuggable while giving up the secret. A redacted
|
|
10
|
+
value carries a short, non-reversible fingerprint, so repeated failures of the
|
|
11
|
+
*same* credential stay recognisable in a log without the credential appearing
|
|
12
|
+
in it.
|
|
13
|
+
|
|
14
|
+
What this can and cannot do is stated in ``SECURITY.md``. In short: values the
|
|
15
|
+
library is *given* as credentials are redacted, and URLs are redacted wherever
|
|
16
|
+
they appear -- including inside a message you supplied and inside the text of a
|
|
17
|
+
wrapped exception. A bare, non-URL secret pasted into free-form text cannot be
|
|
18
|
+
recognised and is not redacted.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import hashlib
|
|
24
|
+
import re
|
|
25
|
+
from typing import Optional
|
|
26
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
27
|
+
|
|
28
|
+
__all__ = [
|
|
29
|
+
"fingerprint",
|
|
30
|
+
"redact_if_url",
|
|
31
|
+
"redact_secret",
|
|
32
|
+
"redact_url",
|
|
33
|
+
"redact_urls_in_text",
|
|
34
|
+
"remove_secret",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
PLACEHOLDER = "***"
|
|
38
|
+
|
|
39
|
+
#: Below this length a "secret" is not removed from free text. Substring
|
|
40
|
+
#: replacement of a short value corrupts ordinary words -- removing "tok" from
|
|
41
|
+
#: "Invalid authentication token" mangles the message and tells a reader
|
|
42
|
+
#: nothing. The structured field is redacted regardless of length.
|
|
43
|
+
MIN_REMOVABLE_SECRET_LENGTH = 8
|
|
44
|
+
|
|
45
|
+
#: Substrings that mark a query or fragment parameter as carrying a secret.
|
|
46
|
+
#: Matched anywhere in the parameter name, case insensitively, so
|
|
47
|
+
#: ``X-Amz-Signature``, ``auth_token`` and ``refresh_token`` are all covered.
|
|
48
|
+
#: Over-matching here is harmless; under-matching leaks.
|
|
49
|
+
SENSITIVE_PARAM_MARKERS = frozenset(
|
|
50
|
+
{
|
|
51
|
+
"auth",
|
|
52
|
+
"credential",
|
|
53
|
+
"key",
|
|
54
|
+
"passwd",
|
|
55
|
+
"password",
|
|
56
|
+
"pwd",
|
|
57
|
+
"secret",
|
|
58
|
+
"session",
|
|
59
|
+
"sig",
|
|
60
|
+
"token",
|
|
61
|
+
}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
#: Finds URLs inside free-form text, so a credential cannot slip through in a
|
|
65
|
+
#: caller-supplied message or in the text of a wrapped exception.
|
|
66
|
+
_URL_IN_TEXT = re.compile(r"\b[a-zA-Z][a-zA-Z0-9+.\-]*://[^\s'\"<>,;)\]}]+")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _is_sensitive(name: str) -> bool:
|
|
70
|
+
lowered = name.lower()
|
|
71
|
+
return any(marker in lowered for marker in SENSITIVE_PARAM_MARKERS)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def fingerprint(value: str) -> str:
|
|
75
|
+
"""Return a short, one-way fingerprint of *value*.
|
|
76
|
+
|
|
77
|
+
Enough to tell "the same bad token again" from "a different bad token",
|
|
78
|
+
and not enough to recover the token.
|
|
79
|
+
"""
|
|
80
|
+
digest = hashlib.sha256(value.encode("utf-8", "replace")).hexdigest()
|
|
81
|
+
return digest[:8]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def redact_secret(value: Optional[str]) -> Optional[str]:
|
|
85
|
+
"""Replace a secret with a placeholder and its fingerprint."""
|
|
86
|
+
if value is None:
|
|
87
|
+
return None
|
|
88
|
+
if not value:
|
|
89
|
+
return PLACEHOLDER
|
|
90
|
+
return f"{PLACEHOLDER}({fingerprint(value)})"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def remove_secret(text: str, secret: Optional[str]) -> str:
|
|
94
|
+
"""Replace every occurrence of a known *secret* in *text*.
|
|
95
|
+
|
|
96
|
+
Used where the library was handed the secret explicitly, so it can be
|
|
97
|
+
removed even from a message the caller wrote themselves.
|
|
98
|
+
"""
|
|
99
|
+
if not secret or not text or len(secret) < MIN_REMOVABLE_SECRET_LENGTH:
|
|
100
|
+
return text
|
|
101
|
+
return text.replace(secret, f"{PLACEHOLDER}({fingerprint(secret)})")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _redact_params(query: str) -> tuple[str, bool]:
|
|
105
|
+
if not query:
|
|
106
|
+
return query, False
|
|
107
|
+
pairs = parse_qsl(query, keep_blank_values=True)
|
|
108
|
+
if not any(_is_sensitive(key) for key, _ in pairs):
|
|
109
|
+
return query, False
|
|
110
|
+
return (
|
|
111
|
+
urlencode(
|
|
112
|
+
[
|
|
113
|
+
(key, PLACEHOLDER if _is_sensitive(key) else value)
|
|
114
|
+
for key, value in pairs
|
|
115
|
+
],
|
|
116
|
+
# Keep the placeholder legible rather than percent-encoded.
|
|
117
|
+
safe="*",
|
|
118
|
+
),
|
|
119
|
+
True,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def redact_url(url: Optional[str], *, keep_path: bool = True) -> Optional[str]:
|
|
124
|
+
"""Strip credentials from *url*.
|
|
125
|
+
|
|
126
|
+
Scheme, host and port are always kept: those are what make an error
|
|
127
|
+
actionable. Userinfo, sensitive query parameters and sensitive fragment
|
|
128
|
+
parameters are always removed.
|
|
129
|
+
|
|
130
|
+
Pass ``keep_path=False`` where the path itself is the credential. An
|
|
131
|
+
incoming webhook URL is the common case -- Slack, Discord and others put
|
|
132
|
+
the secret in the path, so preserving it would defeat the point.
|
|
133
|
+
"""
|
|
134
|
+
if not url:
|
|
135
|
+
return url
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
parts = urlsplit(url)
|
|
139
|
+
except ValueError: # pragma: no cover - urlsplit is extremely permissive
|
|
140
|
+
return PLACEHOLDER
|
|
141
|
+
|
|
142
|
+
if not parts.scheme or not parts.netloc:
|
|
143
|
+
# Not a URL with a host; a bare path or plain string is returned
|
|
144
|
+
# untouched rather than mangled.
|
|
145
|
+
return url
|
|
146
|
+
|
|
147
|
+
redacted = False
|
|
148
|
+
|
|
149
|
+
netloc = parts.netloc
|
|
150
|
+
if "@" in netloc:
|
|
151
|
+
_, _, host = netloc.rpartition("@")
|
|
152
|
+
netloc = f"{PLACEHOLDER}:{PLACEHOLDER}@{host}"
|
|
153
|
+
redacted = True
|
|
154
|
+
|
|
155
|
+
query, query_redacted = _redact_params(parts.query)
|
|
156
|
+
redacted = redacted or query_redacted
|
|
157
|
+
|
|
158
|
+
fragment = parts.fragment
|
|
159
|
+
if "=" in fragment:
|
|
160
|
+
# OAuth implicit flow returns the token in the fragment.
|
|
161
|
+
fragment, fragment_redacted = _redact_params(fragment)
|
|
162
|
+
redacted = redacted or fragment_redacted
|
|
163
|
+
|
|
164
|
+
path = parts.path
|
|
165
|
+
if not keep_path and path.strip("/"):
|
|
166
|
+
path = f"/{PLACEHOLDER}"
|
|
167
|
+
redacted = True
|
|
168
|
+
|
|
169
|
+
if not redacted:
|
|
170
|
+
# Hand back exactly what was passed in. Rebuilding would normalise it,
|
|
171
|
+
# and "sqlite://" loses its slashes on the way through.
|
|
172
|
+
return url
|
|
173
|
+
|
|
174
|
+
return urlunsplit((parts.scheme, netloc, path, query, fragment))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def redact_if_url(value: Optional[str], *, keep_path: bool = True) -> Optional[str]:
|
|
178
|
+
"""Redact *value* only if it is a URL, leaving file paths untouched.
|
|
179
|
+
|
|
180
|
+
Fields such as ``DataLoadingError.source`` document themselves as "file
|
|
181
|
+
path or URL", so they cannot be redacted unconditionally without mangling
|
|
182
|
+
ordinary paths.
|
|
183
|
+
"""
|
|
184
|
+
if not isinstance(value, str) or "://" not in value:
|
|
185
|
+
return value
|
|
186
|
+
return redact_url(value, keep_path=keep_path)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def redact_urls_in_text(text: str, *, keep_path: bool = True) -> str:
|
|
190
|
+
"""Redact every URL found in free-form *text*.
|
|
191
|
+
|
|
192
|
+
This is the boundary that stops a secret being reintroduced after the
|
|
193
|
+
structured argument was redacted -- through a caller-supplied ``message``,
|
|
194
|
+
or through the text of a wrapped exception that quotes the original URL.
|
|
195
|
+
"""
|
|
196
|
+
if not text or "://" not in text:
|
|
197
|
+
return text
|
|
198
|
+
return _URL_IN_TEXT.sub(
|
|
199
|
+
lambda match: redact_url(match.group(0), keep_path=keep_path) or "",
|
|
200
|
+
text,
|
|
201
|
+
)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from .base import DataExceptError
|
|
6
|
-
from .redaction import redact_secret
|
|
6
|
+
from .redaction import redact_secret, remove_secret
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class SecurityError(DataExceptError):
|
|
@@ -62,7 +62,9 @@ class InvalidTokenError(SecurityError):
|
|
|
62
62
|
default = "Invalid authentication token"
|
|
63
63
|
if token:
|
|
64
64
|
default += f": {self.token}"
|
|
65
|
-
|
|
65
|
+
# The library was handed the secret, so it can be removed even from a
|
|
66
|
+
# message the caller wrote themselves.
|
|
67
|
+
super().__init__(remove_secret(message or default, token))
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
__all__ = [
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "DataExcept"
|
|
3
|
-
version = "0.4.
|
|
3
|
+
version = "0.4.1"
|
|
4
4
|
description = "A Python package providing structured, easily-extendable custom exception types."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
7
7
|
license-files = ["LICENSE"]
|
|
8
|
-
requires-python = ">=3.10,<3.
|
|
8
|
+
requires-python = ">=3.10,<3.15"
|
|
9
9
|
authors = [{ name = "Diogo Ribeiro", email = "dfr@esmad.ipp.pt" }]
|
|
10
10
|
maintainers = [{ name = "Diogo Ribeiro", email = "diogo.debastos.ribeiro@gmail.com" }]
|
|
11
11
|
keywords = ["exceptions", "errors", "logging"]
|
|
@@ -16,6 +16,7 @@ classifiers = [
|
|
|
16
16
|
"Programming Language :: Python :: 3.11",
|
|
17
17
|
"Programming Language :: Python :: 3.12",
|
|
18
18
|
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
19
20
|
]
|
|
20
21
|
dependencies = [
|
|
21
22
|
"tomli ; python_version < '3.11'",
|
|
@@ -66,6 +67,10 @@ profile = "black"
|
|
|
66
67
|
|
|
67
68
|
[tool.black]
|
|
68
69
|
line-length = 88
|
|
70
|
+
# Pinned rather than inferred from requires-python: inference picks the highest
|
|
71
|
+
# supported version, and black then refuses to verify its own output when run
|
|
72
|
+
# on an older interpreter -- which is what CI does.
|
|
73
|
+
target-version = ["py310"]
|
|
69
74
|
|
|
70
75
|
[tool.ruff]
|
|
71
76
|
line-length = 88
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
"""The root of the DataExcept exception hierarchy.
|
|
2
|
-
|
|
3
|
-
Every exception this package raises derives from :class:`DataExceptError`, so a
|
|
4
|
-
caller can catch everything the library can raise with a single clause while
|
|
5
|
-
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
|
-
The base also gives the whole hierarchy a working serialization contract. Many
|
|
15
|
-
of these exceptions take several constructor arguments while ``Exception.args``
|
|
16
|
-
holds only the rendered message, so the default pickling protocol -- which
|
|
17
|
-
replays ``args`` through ``__init__`` -- could not rebuild them. That made them
|
|
18
|
-
unusable across a process boundary, which is where a data pipeline most needs
|
|
19
|
-
them.
|
|
20
|
-
"""
|
|
21
|
-
|
|
22
|
-
from __future__ import annotations
|
|
23
|
-
|
|
24
|
-
from typing import Any, Dict, Tuple, Type
|
|
25
|
-
|
|
26
|
-
__all__ = ["DataExceptError"]
|
|
27
|
-
|
|
28
|
-
#: Attribute names used across the package to hold the exception that caused
|
|
29
|
-
#: this one. Checked in order; the first that holds an exception wins.
|
|
30
|
-
_CAUSE_ATTRIBUTES = ("original", "original_exception", "cause")
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def _rebuild(
|
|
34
|
-
cls: Type["DataExceptError"], args: Tuple[Any, ...], state: Dict[str, Any]
|
|
35
|
-
) -> "DataExceptError":
|
|
36
|
-
"""Recreate *cls* without replaying its ``__init__``.
|
|
37
|
-
|
|
38
|
-
Constructors here validate and render a message from their arguments;
|
|
39
|
-
replaying them would need the original arguments, which ``args`` does not
|
|
40
|
-
carry. Restoring ``args`` and ``__dict__` directly reproduces the exception
|
|
41
|
-
exactly, including its message and every attribute it recorded.
|
|
42
|
-
"""
|
|
43
|
-
exc = cls.__new__(cls)
|
|
44
|
-
Exception.__init__(exc, *args)
|
|
45
|
-
exc.__dict__.update(state)
|
|
46
|
-
return exc
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class DataExceptError(Exception):
|
|
50
|
-
"""Base class for every exception DataExcept raises."""
|
|
51
|
-
|
|
52
|
-
def __init__(self, *args: Any) -> None:
|
|
53
|
-
super().__init__(*args)
|
|
54
|
-
# Constructors that wrap another exception record it on an attribute.
|
|
55
|
-
# Mirroring it into __cause__ is what makes a traceback print the
|
|
56
|
-
# underlying failure, exactly as `raise ... from exc` would; assigning
|
|
57
|
-
# __cause__ also sets __suppress_context__, as `raise from` does.
|
|
58
|
-
for attribute in _CAUSE_ATTRIBUTES:
|
|
59
|
-
candidate = getattr(self, attribute, None)
|
|
60
|
-
if isinstance(candidate, BaseException):
|
|
61
|
-
self.__cause__ = candidate
|
|
62
|
-
break
|
|
63
|
-
|
|
64
|
-
def __reduce__(
|
|
65
|
-
self,
|
|
66
|
-
) -> Tuple[Any, Tuple[Type["DataExceptError"], Tuple[Any, ...], Dict[str, Any]]]:
|
|
67
|
-
return (_rebuild, (type(self), self.args, self.__dict__.copy()))
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
"""Redaction helpers for values that must not reach a log.
|
|
2
|
-
|
|
3
|
-
Several exceptions here are raised with credentials in hand: an authentication
|
|
4
|
-
token, a database URL carrying a password, a webhook URL with a signing
|
|
5
|
-
parameter. Those values end up in the exception message, and
|
|
6
|
-
:func:`dataexcept.logging_helpers.log_exception` logs ``str(exc)``, so without
|
|
7
|
-
redaction a failed connection writes the password to the log.
|
|
8
|
-
|
|
9
|
-
The aim is to keep an error debuggable while giving up the secret. A redacted
|
|
10
|
-
value carries a short, non-reversible fingerprint, so repeated failures of the
|
|
11
|
-
*same* credential are still recognisable in a log without the credential
|
|
12
|
-
appearing in it.
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import hashlib
|
|
18
|
-
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
19
|
-
|
|
20
|
-
__all__ = ["fingerprint", "redact_secret", "redact_url"]
|
|
21
|
-
|
|
22
|
-
PLACEHOLDER = "***"
|
|
23
|
-
|
|
24
|
-
#: Query parameters whose value is treated as a secret. Matched case
|
|
25
|
-
#: insensitively against the whole parameter name.
|
|
26
|
-
SENSITIVE_QUERY_PARAMS = frozenset(
|
|
27
|
-
{
|
|
28
|
-
"access_token",
|
|
29
|
-
"api_key",
|
|
30
|
-
"apikey",
|
|
31
|
-
"auth",
|
|
32
|
-
"credential",
|
|
33
|
-
"key",
|
|
34
|
-
"password",
|
|
35
|
-
"private_key",
|
|
36
|
-
"pwd",
|
|
37
|
-
"refresh_token",
|
|
38
|
-
"secret",
|
|
39
|
-
"session",
|
|
40
|
-
"sig",
|
|
41
|
-
"signature",
|
|
42
|
-
"token",
|
|
43
|
-
}
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def fingerprint(value: str) -> str:
|
|
48
|
-
"""Return a short, one-way fingerprint of *value*.
|
|
49
|
-
|
|
50
|
-
Enough to tell "the same bad token again" from "a different bad token",
|
|
51
|
-
and not enough to recover the token.
|
|
52
|
-
"""
|
|
53
|
-
digest = hashlib.sha256(value.encode("utf-8", "replace")).hexdigest()
|
|
54
|
-
return digest[:8]
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def redact_secret(value: str | None) -> str | None:
|
|
58
|
-
"""Replace a secret with a placeholder and its fingerprint."""
|
|
59
|
-
if value is None:
|
|
60
|
-
return None
|
|
61
|
-
if not value:
|
|
62
|
-
return PLACEHOLDER
|
|
63
|
-
return f"{PLACEHOLDER}({fingerprint(value)})"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def redact_url(url: str | None) -> str | None:
|
|
67
|
-
"""Strip credentials and sensitive query parameters from *url*.
|
|
68
|
-
|
|
69
|
-
The scheme, host, port and path are kept, because those are what make the
|
|
70
|
-
error actionable. Anything that authenticates is replaced.
|
|
71
|
-
"""
|
|
72
|
-
if not url:
|
|
73
|
-
return url
|
|
74
|
-
|
|
75
|
-
try:
|
|
76
|
-
parts = urlsplit(url)
|
|
77
|
-
except ValueError: # pragma: no cover - urlsplit is extremely permissive
|
|
78
|
-
return PLACEHOLDER
|
|
79
|
-
|
|
80
|
-
if not parts.scheme and not parts.netloc:
|
|
81
|
-
# Not a URL at all; treat the whole thing as sensitive rather than
|
|
82
|
-
# returning it unchanged.
|
|
83
|
-
return url
|
|
84
|
-
|
|
85
|
-
netloc = parts.netloc
|
|
86
|
-
redacted = False
|
|
87
|
-
if "@" in netloc:
|
|
88
|
-
_, _, host = netloc.rpartition("@")
|
|
89
|
-
netloc = f"{PLACEHOLDER}:{PLACEHOLDER}@{host}"
|
|
90
|
-
redacted = True
|
|
91
|
-
|
|
92
|
-
query = parts.query
|
|
93
|
-
if query:
|
|
94
|
-
pairs = parse_qsl(query, keep_blank_values=True)
|
|
95
|
-
if any(key.lower() in SENSITIVE_QUERY_PARAMS for key, _ in pairs):
|
|
96
|
-
query = urlencode(
|
|
97
|
-
[
|
|
98
|
-
(
|
|
99
|
-
key,
|
|
100
|
-
PLACEHOLDER if key.lower() in SENSITIVE_QUERY_PARAMS else value,
|
|
101
|
-
)
|
|
102
|
-
for key, value in pairs
|
|
103
|
-
],
|
|
104
|
-
# Keep the placeholder legible rather than percent-encoded.
|
|
105
|
-
safe="*",
|
|
106
|
-
)
|
|
107
|
-
redacted = True
|
|
108
|
-
|
|
109
|
-
if not redacted:
|
|
110
|
-
# Nothing sensitive, so hand back exactly what was passed in.
|
|
111
|
-
# Rebuilding would normalise it -- "sqlite://" loses its slashes.
|
|
112
|
-
return url
|
|
113
|
-
|
|
114
|
-
return urlunsplit((parts.scheme, netloc, parts.path, query, parts.fragment))
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|