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

1# parsing.py 

2"""Failures reading data the caller did not write. 

3 

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. 

10 

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. 

16 

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""" 

20 

21from __future__ import annotations 

22 

23from .._causes import resolve_cause 

24from .._previews import bounded_preview 

25from ..redaction import redact_if_url 

26from .base import JobError 

27 

28 

29def _describe_target(format: str | None, source: str | None) -> str: 

30 """Name what was being read, from whichever context the caller gave. 

31 

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" 

43 

44 

45class ParsingError(JobError): 

46 """Raised when parsing of input data fails. 

47 

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 """ 

59 

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) 

77 

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 

86 

87 

88class SerializationError(JobError): 

89 """Raised when serialization of an object fails.""" 

90 

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) 

96 

97 

98class DeserializationError(JobError): 

99 """Raised when deserialization of data fails. 

100 

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 """ 

112 

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) 

130 

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