Coverage for dataexcept/exceptions/parsing.py: 100%
47 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-27 14:44 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-27 14:44 +0000
1# parsing.py
2"""Failures reading data the caller did not write.
4Parsing and deserialization fail on external content: an API response, a queue
5message, a file from somewhere else. The obvious way to report that is to keep
6the offending payload on the exception, and that is what these classes did --
7which meant an error body containing a token, a page of personal data, or a
8megabyte of binary all ended up in the message, the log line, and every
9structured envelope derived from them.
11Callers worked around it by passing a synthetic value such as ``"API
12response"``, and the package lost the structured context in exchange. So the
13payload is no longer the only way to say what failed: ``source`` records where
14the content came from, ``format`` what it was meant to be, and ``preview`` a
15bounded excerpt for the cases where seeing the content is the whole point.
17The payload parameters still work and still keep what they are given. Passing
18one is now the opt-in rather than the only option.
19"""
21from __future__ import annotations
23from .._causes import resolve_cause
24from .._previews import bounded_preview
25from ..redaction import redact_if_url
26from .base import JobError
29def _describe_target(format: str | None, source: str | None) -> str:
30 """Name what was being read, from whichever context the caller gave.
32 The source goes in brackets rather than trailing the sentence so that
33 what was being read stays visually separate from where it came from once a
34 cause is appended after it.
35 """
36 if format and source:
37 return f"{format} ({source})"
38 if format:
39 return f"{format} input"
40 if source:
41 return f"input ({source})"
42 return "input"
45class ParsingError(JobError):
46 """Raised when parsing of input data fails.
48 Args:
49 text: The offending content. Optional, and kept verbatim when given:
50 pass it only when the content is yours and safe to retain.
51 message: Overrides the generated message entirely.
52 source: Where the content came from -- an endpoint, path or queue.
53 Redacted when it is a URL, left alone when it is a file path.
54 format: What the content was meant to be, such as ``"json"``.
55 preview: A bounded excerpt of the content, truncated to
56 :data:`dataexcept._previews.MAX_PREVIEW_LENGTH` characters.
57 cause: The underlying exception, also set as ``__cause__``.
58 """
60 def __init__(
61 self,
62 text: str | None = None,
63 message: str | None = None,
64 *,
65 source: str | None = None,
66 format: str | None = None,
67 preview: str | bytes | bytearray | None = None,
68 cause: Exception | None = None,
69 ) -> None:
70 self.text = text
71 self.source = redact_if_url(source)
72 self.format = format
73 self.preview = bounded_preview(preview)
74 self.cause = resolve_cause(cause=cause)
75 self.message = message or self._default_message()
76 super().__init__(self.message)
78 def _default_message(self) -> str:
79 if self.text is not None and self.source is None and self.format is None:
80 # The historical message, with the payload bounded. Truncating the
81 # text before repr keeps the quotes balanced.
82 described = f"Failed to parse text: {bounded_preview(self.text)!r}"
83 else:
84 described = f"Failed to parse {_describe_target(self.format, self.source)}"
85 return f"{described}: {self.cause}" if self.cause else described
88class SerializationError(JobError):
89 """Raised when serialization of an object fails."""
91 def __init__(self, obj, format: str, message: str | None = None):
92 self.obj = obj
93 self.format = format
94 self.message = message or f"Failed to serialize object to {format}"
95 super().__init__(self.message)
98class DeserializationError(JobError):
99 """Raised when deserialization of data fails.
101 Args:
102 data: The offending bytes. Optional, and kept verbatim when given:
103 pass it only when the payload is yours and safe to retain.
104 format: What the payload was meant to be, such as ``"json"``.
105 message: Overrides the generated message entirely.
106 source: Where the payload came from -- an endpoint, path or queue.
107 Redacted when it is a URL, left alone when it is a file path.
108 preview: A bounded excerpt of the payload, truncated to
109 :data:`dataexcept._previews.MAX_PREVIEW_LENGTH` characters.
110 cause: The underlying exception, also set as ``__cause__``.
111 """
113 def __init__(
114 self,
115 data: bytes | None = None,
116 format: str | None = None,
117 message: str | None = None,
118 *,
119 source: str | None = None,
120 preview: str | bytes | bytearray | None = None,
121 cause: Exception | None = None,
122 ) -> None:
123 self.data = data
124 self.format = format
125 self.source = redact_if_url(source)
126 self.preview = bounded_preview(preview)
127 self.cause = resolve_cause(cause=cause)
128 self.message = message or self._default_message()
129 super().__init__(self.message)
131 def _default_message(self) -> str:
132 if self.data is not None and self.format is not None and self.source is None:
133 # Exactly the call the historical signature accepted, so exactly
134 # the message it produced.
135 described = f"Failed to deserialize data from {self.format}"
136 else:
137 described = (
138 f"Failed to deserialize {_describe_target(self.format, self.source)}"
139 )
140 return f"{described}: {self.cause}" if self.cause else described