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.
Files changed (39) hide show
  1. {dataexcept-0.4.0 → dataexcept-0.4.1}/CHANGELOG.md +120 -2
  2. {dataexcept-0.4.0 → dataexcept-0.4.1}/CITATION.cff +2 -2
  3. {dataexcept-0.4.0 → dataexcept-0.4.1}/PKG-INFO +11 -10
  4. {dataexcept-0.4.0 → dataexcept-0.4.1}/README.md +8 -8
  5. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/__init__.py +6 -2
  6. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/_validation.py +7 -1
  7. dataexcept-0.4.1/dataexcept/base.py +190 -0
  8. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/dataengineering_exceptions.py +2 -1
  9. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/datascience_exceptions/ingestion.py +2 -1
  10. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/datascience_exceptions/operations.py +2 -1
  11. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/notification.py +7 -2
  12. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/io_exceptions.py +4 -3
  13. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/logging_helpers.py +41 -1
  14. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/pandas_exceptions.py +7 -5
  15. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/pipeline_exceptions.py +3 -3
  16. dataexcept-0.4.1/dataexcept/redaction.py +201 -0
  17. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/security_exceptions.py +4 -2
  18. {dataexcept-0.4.0 → dataexcept-0.4.1}/pyproject.toml +7 -2
  19. dataexcept-0.4.0/dataexcept/base.py +0 -67
  20. dataexcept-0.4.0/dataexcept/redaction.py +0 -114
  21. {dataexcept-0.4.0 → dataexcept-0.4.1}/LICENSE +0 -0
  22. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/__main__.py +0 -0
  23. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/_deprecation.py +0 -0
  24. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/database_exceptions.py +0 -0
  25. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/datascience_exceptions/__init__.py +0 -0
  26. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/datascience_exceptions/base.py +0 -0
  27. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/datascience_exceptions/training.py +0 -0
  28. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/__init__.py +0 -0
  29. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/authentication.py +0 -0
  30. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/base.py +0 -0
  31. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/configuration.py +0 -0
  32. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/external.py +0 -0
  33. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/lifecycle.py +0 -0
  34. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/parsing.py +0 -0
  35. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/scheduling.py +0 -0
  36. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/exceptions/validation.py +0 -0
  37. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/job_exceptions.py +0 -0
  38. {dataexcept-0.4.0 → dataexcept-0.4.1}/dataexcept/network_exceptions.py +0 -0
  39. {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.0...HEAD
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.0"
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-24"
14
+ date-released: "2026-08-25"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DataExcept
3
- Version: 0.4.0
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.14
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 everything via `DataExceptError`
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**: 99 exception classes covering ML pipelines, feature engineering, model training
47
- - **🔧 Production Ready**: Logging helpers, error context, and exceptions that survive a process boundary intact
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+**: Modern Python with full type safety
50
- - **🧪 Well Tested**: 690+ tests at 92% branch coverage of the package, gated in CI
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 (99 of them, alphabetically)
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.0
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.0},
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 everything via `DataExceptError`
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**: 99 exception classes covering ML pipelines, feature engineering, model training
21
- - **🔧 Production Ready**: Logging helpers, error context, and exceptions that survive a process boundary intact
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+**: Modern Python with full type safety
24
- - **🧪 Well Tested**: 690+ tests at 92% branch coverage of the package, gated in CI
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 (99 of them, alphabetically)
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.0
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.0},
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 anything this package raises.
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
- return isinstance(value, numbers.Real)
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
- # Webhook URLs commonly authenticate through a query parameter.
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=_build_extra(context))
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
- if not all(isinstance(k, str) for k in left_keys):
102
- raise TypeError("left_keys must be a sequence of strings")
103
- if not all(isinstance(k, str) for k in right_keys):
104
- raise TypeError("right_keys must be a sequence of strings")
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
- super().__init__(message or default)
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.0"
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.14"
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