DataExcept 1.0.0__tar.gz → 1.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {dataexcept-1.0.0 → dataexcept-1.2.0}/CHANGELOG.md +41 -1
  2. {dataexcept-1.0.0 → dataexcept-1.2.0}/CITATION.cff +2 -2
  3. {dataexcept-1.0.0 → dataexcept-1.2.0}/PKG-INFO +1 -1
  4. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/__init__.py +8 -0
  5. dataexcept-1.2.0/dataexcept/serialization.py +216 -0
  6. dataexcept-1.2.0/dataexcept/wrapping.py +103 -0
  7. {dataexcept-1.0.0 → dataexcept-1.2.0}/pyproject.toml +1 -1
  8. {dataexcept-1.0.0 → dataexcept-1.2.0}/LICENSE +0 -0
  9. {dataexcept-1.0.0 → dataexcept-1.2.0}/README.md +0 -0
  10. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/__main__.py +0 -0
  11. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/_validation.py +0 -0
  12. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/base.py +0 -0
  13. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/database_exceptions.py +0 -0
  14. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/dataengineering_exceptions.py +0 -0
  15. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/datascience_exceptions/__init__.py +0 -0
  16. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/datascience_exceptions/base.py +0 -0
  17. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/datascience_exceptions/ingestion.py +0 -0
  18. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/datascience_exceptions/operations.py +0 -0
  19. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/datascience_exceptions/training.py +0 -0
  20. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/exceptions/__init__.py +0 -0
  21. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/exceptions/authentication.py +0 -0
  22. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/exceptions/base.py +0 -0
  23. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/exceptions/configuration.py +0 -0
  24. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/exceptions/external.py +0 -0
  25. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/exceptions/lifecycle.py +0 -0
  26. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/exceptions/notification.py +0 -0
  27. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/exceptions/parsing.py +0 -0
  28. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/exceptions/scheduling.py +0 -0
  29. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/exceptions/validation.py +0 -0
  30. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/io_exceptions.py +0 -0
  31. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/logging_helpers.py +0 -0
  32. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/network_exceptions.py +0 -0
  33. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/pandas_exceptions.py +0 -0
  34. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/pipeline_exceptions.py +0 -0
  35. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/py.typed +0 -0
  36. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/redaction.py +0 -0
  37. {dataexcept-1.0.0 → dataexcept-1.2.0}/dataexcept/security_exceptions.py +0 -0
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.0] - 2026-09-02
11
+
12
+ ### Added
13
+
14
+ - **`exception_to_dict` and `exception_to_json`** provide a stable, strict
15
+ JSON-safe envelope for exceptions crossing API, queue, telemetry and
16
+ structured-log boundaries. The envelope preserves type, module, rendered
17
+ message, public attributes and bounded cause/context chains.
18
+ - Structured export degrades hostile or unserialisable values safely, converts
19
+ non-finite floats and byte payloads to text, marks cycles and depth
20
+ truncation, and excludes traceback frames and private attributes.
21
+ - Export uses a stricter redaction boundary than ordinary exception rendering:
22
+ credential-bearing URL paths are removed as well as sensitive query and
23
+ fragment values, including in third-party causes, mapping keys and arbitrary
24
+ attribute representations.
25
+
26
+ ## [1.1.0] - 2026-09-02
27
+
28
+ ### Added
29
+
30
+ - **`wrap` and `wrapping`**, for turning a third-party exception into a
31
+ DataExcept one:
32
+
33
+ ```python
34
+ with wrapping(OSError, DataLoadingError, source=path):
35
+ frame = pd.read_csv(path)
36
+ ```
37
+
38
+ The hand-written form is easy to get subtly wrong: a missing `from exc` loses
39
+ the traceback, the original passed to the wrong parameter is not recorded,
40
+ and a broad `except` relabels a `KeyboardInterrupt` as a data error. These
41
+ settle the wiring — the original goes to whichever constructor parameter
42
+ takes a cause (`original`, `original_exception` or `cause`) and is set as
43
+ `__cause__` either way, which matters because only 17 of the 100 classes
44
+ record one on an attribute.
45
+ - Guidance in the advanced usage guide on building a project-specific
46
+ hierarchy on these bases, and what deriving from a domain root gets you.
47
+
10
48
  ## [1.0.0] - 2026-08-25
11
49
 
12
50
  First stable release. The public API is frozen; see the
@@ -509,7 +547,9 @@ First public release.
509
547
  - Published to PyPI via OIDC trusted publishing; no long-lived API token is
510
548
  involved in a release.
511
549
 
512
- [Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.0.0...HEAD
550
+ [Unreleased]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.2.0...HEAD
551
+ [1.2.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.1.0...v1.2.0
552
+ [1.1.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v1.0.0...v1.1.0
513
553
  [1.0.0]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.3...v1.0.0
514
554
  [0.4.3]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.2...v0.4.3
515
555
  [0.4.2]: https://github.com/DiogoRibeiro7/DataExcept/compare/v0.4.1...v0.4.2
@@ -1,7 +1,7 @@
1
1
  cff-version: 1.2.0
2
2
  message: "If you use this software, please cite it using the following metadata."
3
3
  title: "DataExcept"
4
- version: "1.0.0"
4
+ version: "1.2.0"
5
5
  authors:
6
6
  - family-names: "Ribeiro"
7
7
  given-names: "Diogo"
@@ -11,4 +11,4 @@ authors:
11
11
  type: software
12
12
  url: "https://github.com/DiogoRibeiro7/DataExcept"
13
13
  repository-code: "https://github.com/DiogoRibeiro7/DataExcept"
14
- date-released: "2026-08-25"
14
+ date-released: "2026-09-02"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: DataExcept
3
- Version: 1.0.0
3
+ Version: 1.2.0
4
4
  Summary: A Python package providing structured, easily-extendable custom exception types.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -159,6 +159,8 @@ from .security_exceptions import (
159
159
  InvalidTokenError,
160
160
  SecurityError,
161
161
  )
162
+ from .serialization import exception_to_dict, exception_to_json
163
+ from .wrapping import wrap, wrapping
162
164
 
163
165
  __all__ = [
164
166
  # The root of the hierarchy: catches every operational exception the
@@ -266,6 +268,12 @@ __all__ = [
266
268
  "UnderfittingError",
267
269
  "ValidationError",
268
270
  "WebhookError",
271
+ # Turning a third-party exception into one of these.
272
+ "wrap",
273
+ "wrapping",
274
+ # Structured serialization for APIs, queues and telemetry.
275
+ "exception_to_dict",
276
+ "exception_to_json",
269
277
  # Logging helpers.
270
278
  "Context",
271
279
  "log_and_raise",
@@ -0,0 +1,216 @@
1
+ """Strict JSON-safe structured representations of exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from collections.abc import Mapping, Sequence, Set
8
+ from typing import Any
9
+
10
+ from .redaction import redact_urls_in_text
11
+
12
+ __all__ = ["exception_to_dict", "exception_to_json"]
13
+
14
+ _MAX_VALUE_DEPTH = 8
15
+ _NOT_SCALAR = object()
16
+
17
+
18
+ def _redact_export_text(text: str) -> str:
19
+ """Scrub URLs for export, including their paths.
20
+
21
+ Normal DataExcept messages preserve URL paths because paths are commonly
22
+ useful debugging context. Structured envelopes have a stricter boundary:
23
+ third-party errors and arbitrary caller state may put credentials in the
24
+ path itself, so exported text never preserves URL paths.
25
+ """
26
+ return redact_urls_in_text(text, keep_path=False)
27
+
28
+
29
+ def _safe_text(value: Any) -> str:
30
+ """Render *value* without raising and scrub credential-bearing URLs."""
31
+ try:
32
+ text = str(value)
33
+ except Exception:
34
+ try:
35
+ text = f"<unrepresentable {type(value).__name__}>"
36
+ except Exception: # pragma: no cover - hostile type metadata
37
+ text = "<unrepresentable>"
38
+ return _redact_export_text(text)
39
+
40
+
41
+ def _safe_key(value: Any) -> str:
42
+ if isinstance(value, str):
43
+ return _redact_export_text(value)
44
+ return _safe_text(value)
45
+
46
+
47
+ def _safe_scalar(value: Any) -> Any:
48
+ if value is None or isinstance(value, (bool, int)):
49
+ return value
50
+ if isinstance(value, float):
51
+ return value if math.isfinite(value) else str(value)
52
+ if isinstance(value, str):
53
+ return _redact_export_text(value)
54
+ if isinstance(value, (bytes, bytearray)):
55
+ return _safe_text(value)
56
+ return _NOT_SCALAR
57
+
58
+
59
+ def _safe_mapping(value: Mapping[Any, Any], *, depth: int, seen: set[int]) -> Any:
60
+ identity = id(value)
61
+ seen.add(identity)
62
+ try:
63
+ return {
64
+ _safe_key(key): _json_safe(item, depth=depth + 1, seen=seen)
65
+ for key, item in value.items()
66
+ }
67
+ except Exception:
68
+ return _safe_text(value)
69
+ finally:
70
+ seen.discard(identity)
71
+
72
+
73
+ def _safe_collection(
74
+ value: Sequence[Any] | Set[Any], *, depth: int, seen: set[int]
75
+ ) -> Any:
76
+ identity = id(value)
77
+ seen.add(identity)
78
+ try:
79
+ return [_json_safe(item, depth=depth + 1, seen=seen) for item in value]
80
+ except Exception:
81
+ return _safe_text(value)
82
+ finally:
83
+ seen.discard(identity)
84
+
85
+
86
+ def _json_safe(value: Any, *, depth: int = 0, seen: set[int] | None = None) -> Any:
87
+ """Return *value* in a strict JSON-safe form without raising."""
88
+ if seen is None:
89
+ seen = set()
90
+ scalar = _safe_scalar(value)
91
+ if scalar is not _NOT_SCALAR:
92
+ return scalar
93
+ if depth >= _MAX_VALUE_DEPTH:
94
+ return "<truncated>"
95
+ if id(value) in seen:
96
+ return "<cycle>"
97
+ if isinstance(value, Mapping):
98
+ return _safe_mapping(value, depth=depth, seen=seen)
99
+ if isinstance(value, (Sequence, Set)):
100
+ return _safe_collection(value, depth=depth, seen=seen)
101
+ return _safe_text(value)
102
+
103
+
104
+ def _attributes(exc: BaseException) -> dict[str, Any]:
105
+ """Return public instance attributes in a JSON-safe representation."""
106
+ try:
107
+ state = vars(exc)
108
+ result: dict[str, Any] = {}
109
+ for name, value in state.items():
110
+ if not isinstance(name, str) or name.startswith("_"):
111
+ continue
112
+ result[name] = _json_safe(value)
113
+ return result
114
+ except Exception:
115
+ # This code runs at an error-transport boundary. A hostile __dict__,
116
+ # custom mapping, iterator or key must not replace the original failure
117
+ # with a serialization failure.
118
+ return {}
119
+
120
+
121
+ def _exception_record(
122
+ exc: BaseException,
123
+ *,
124
+ include_attributes: bool,
125
+ max_depth: int,
126
+ depth: int,
127
+ seen: set[int],
128
+ ) -> dict[str, Any]:
129
+ if depth > max_depth:
130
+ return {"truncated": True}
131
+
132
+ identity = id(exc)
133
+ if identity in seen:
134
+ return {
135
+ "type": type(exc).__name__,
136
+ "module": type(exc).__module__,
137
+ "message": _safe_text(exc),
138
+ "cycle": True,
139
+ }
140
+
141
+ seen.add(identity)
142
+ record: dict[str, Any] = {
143
+ "type": type(exc).__name__,
144
+ "module": type(exc).__module__,
145
+ "message": _safe_text(exc),
146
+ }
147
+ if include_attributes:
148
+ attributes = _attributes(exc)
149
+ if attributes:
150
+ record["attributes"] = attributes
151
+
152
+ if exc.__cause__ is not None:
153
+ record["cause"] = _exception_record(
154
+ exc.__cause__,
155
+ include_attributes=include_attributes,
156
+ max_depth=max_depth,
157
+ depth=depth + 1,
158
+ seen=seen,
159
+ )
160
+ if exc.__context__ is not None and not exc.__suppress_context__:
161
+ record["context"] = _exception_record(
162
+ exc.__context__,
163
+ include_attributes=include_attributes,
164
+ max_depth=max_depth,
165
+ depth=depth + 1,
166
+ seen=seen,
167
+ )
168
+
169
+ seen.discard(identity)
170
+ return record
171
+
172
+
173
+ def exception_to_dict(
174
+ exc: BaseException,
175
+ *,
176
+ include_attributes: bool = True,
177
+ max_depth: int = 8,
178
+ ) -> dict[str, Any]:
179
+ """Return a strict JSON-safe structured representation of *exc*.
180
+
181
+ The representation contains the exception type, module and rendered
182
+ message, optionally public instance attributes, and bounded cause/context
183
+ chains. Traceback frames and private attributes are deliberately excluded.
184
+ """
185
+ if not isinstance(exc, BaseException):
186
+ raise TypeError("exc must be an exception instance")
187
+ if not isinstance(max_depth, int) or isinstance(max_depth, bool):
188
+ raise TypeError("max_depth must be an integer")
189
+ if max_depth < 0:
190
+ raise ValueError("max_depth must be non-negative")
191
+ return _exception_record(
192
+ exc,
193
+ include_attributes=include_attributes,
194
+ max_depth=max_depth,
195
+ depth=0,
196
+ seen=set(),
197
+ )
198
+
199
+
200
+ def exception_to_json(
201
+ exc: BaseException,
202
+ *,
203
+ include_attributes: bool = True,
204
+ max_depth: int = 8,
205
+ **json_kwargs: Any,
206
+ ) -> str:
207
+ """Return :func:`exception_to_dict` encoded as strict JSON."""
208
+ json_kwargs["allow_nan"] = False
209
+ return json.dumps(
210
+ exception_to_dict(
211
+ exc,
212
+ include_attributes=include_attributes,
213
+ max_depth=max_depth,
214
+ ),
215
+ **json_kwargs,
216
+ )
@@ -0,0 +1,103 @@
1
+ """Turning a third-party exception into a DataExcept one.
2
+
3
+ The pattern this replaces is everywhere in pipeline code::
4
+
5
+ try:
6
+ frame = pd.read_csv(path)
7
+ except OSError as exc:
8
+ raise DataLoadingError(path, exc) from exc
9
+
10
+ It is easy to write and easy to get subtly wrong: forget the ``from exc`` and
11
+ the traceback stops showing what actually failed; pass the original to the
12
+ wrong parameter and it is not recorded at all; catch too broadly and a
13
+ ``KeyboardInterrupt`` becomes a data-loading error.
14
+
15
+ :func:`wrap` and :func:`wrapping` do the same thing with the wiring settled.
16
+ The original is passed to whichever constructor parameter takes a cause --
17
+ ``original``, ``original_exception`` or ``cause``, whichever that class uses --
18
+ and set as ``__cause__`` either way, so a traceback always shows both.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import inspect
25
+ from typing import Any, Iterator, Tuple, Type, Union
26
+
27
+ from .base import DataExceptError
28
+
29
+ __all__ = ["wrap", "wrapping"]
30
+
31
+ #: Constructor parameter names used across the package for a wrapped
32
+ #: exception. Checked in this order; the first the target accepts wins.
33
+ _CAUSE_PARAMETERS = ("original", "original_exception", "cause")
34
+
35
+ Catchable = Union[Type[BaseException], Tuple[Type[BaseException], ...]]
36
+
37
+
38
+ def _cause_parameter(target: Type[DataExceptError]) -> str | None:
39
+ """Return the parameter of *target* that takes a wrapped exception."""
40
+ try:
41
+ parameters = inspect.signature(target.__init__).parameters
42
+ except (TypeError, ValueError): # pragma: no cover - builtins and C types
43
+ return None
44
+ for name in _CAUSE_PARAMETERS:
45
+ if name in parameters:
46
+ return name
47
+ return None
48
+
49
+
50
+ def wrap(
51
+ original: BaseException,
52
+ target: Type[DataExceptError],
53
+ /,
54
+ **kwargs: Any,
55
+ ) -> DataExceptError:
56
+ """Build *target* from *original*, recording it as the cause.
57
+
58
+ Extra keyword arguments go to the constructor::
59
+
60
+ raise wrap(exc, DataLoadingError, source=path) from exc
61
+
62
+ If *target* accepts a cause parameter, *original* is passed to it. Either
63
+ way ``__cause__`` is set, so a traceback shows the underlying failure even
64
+ for a class that records nothing.
65
+
66
+ An explicit ``original``/``cause`` keyword wins, so a caller can still say
67
+ exactly what they mean.
68
+ """
69
+ parameter = _cause_parameter(target)
70
+ if parameter is not None and parameter not in kwargs:
71
+ kwargs[parameter] = original
72
+
73
+ exception = target(**kwargs)
74
+ # Set unconditionally: the target may record nothing, and the point is that
75
+ # the traceback shows what actually failed.
76
+ exception.__cause__ = original
77
+ return exception
78
+
79
+
80
+ @contextlib.contextmanager
81
+ def wrapping(
82
+ catch: Catchable,
83
+ target: Type[DataExceptError],
84
+ /,
85
+ **kwargs: Any,
86
+ ) -> Iterator[None]:
87
+ """Translate *catch* raised inside the block into *target*.
88
+
89
+ ::
90
+
91
+ with wrapping(OSError, DataLoadingError, source=path):
92
+ frame = pd.read_csv(path)
93
+
94
+ Only exceptions matching *catch* are translated; everything else propagates
95
+ untouched, including anything already raised by this package. Because
96
+ *catch* is given explicitly there is no default broad ``except``, so a
97
+ ``KeyboardInterrupt`` or a bug in the block is never relabelled as a data
98
+ error.
99
+ """
100
+ try:
101
+ yield
102
+ except catch as exc:
103
+ raise wrap(exc, target, **kwargs) from exc
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "DataExcept"
3
- version = "1.0.0"
3
+ version = "1.2.0"
4
4
  description = "A Python package providing structured, easily-extendable custom exception types."
5
5
  readme = "README.md"
6
6
  license = "MIT"
File without changes
File without changes