Skip to content

Commit 9cc3b60

Browse files
authored
Traceback serialization (#75)
1 parent f9c6621 commit 9cc3b60

File tree

7 files changed

+124
-19
lines changed

7 files changed

+124
-19
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,6 @@ poetry.toml
174174
pyrightconfig.json
175175

176176
# End of https://www.toptal.com/developers/gitignore/api/python
177+
178+
# Editor config files
179+
.vscode

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,15 @@ If a task raised an exception, its `.result` will be the exception raised:
158158
assert isinstance(result.result, ValueError)
159159
```
160160

161-
As part of the serialization process for exceptions, some information, such as the traceback information, is lost. If the exception could not be serialized, the `.result` is `None`.
161+
As part of the serialization process for exceptions, some information is lost. The traceback information is reduced to a string that you can print to help debugging:
162+
163+
```python
164+
assert isinstance(result.traceback, str)
165+
```
166+
167+
The stack frames, `globals()` and `locals()` are not available.
168+
169+
If the exception could not be serialized, the `.result` is `None`.
162170

163171
### Backend introspecting
164172

django_tasks/task.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from typing_extensions import ParamSpec, Self
2222

2323
from .exceptions import ResultDoesNotExist
24-
from .utils import exception_from_dict, get_module_path
24+
from .utils import SerializedExceptionDict, exception_from_dict, get_module_path
2525

2626
if TYPE_CHECKING:
2727
from .backends.base import BaseTaskBackend
@@ -226,21 +226,33 @@ class TaskResult(Generic[T]):
226226
backend: str
227227
"""The name of the backend the task will run on"""
228228

229-
_result: Optional[Union[T, dict]] = field(init=False, default=None)
229+
_result: Optional[Union[T, SerializedExceptionDict]] = field(
230+
init=False, default=None
231+
)
230232

231233
@property
232234
def result(self) -> Optional[Union[T, BaseException]]:
233235
if self.status == ResultStatus.COMPLETE:
234236
return cast(T, self._result)
235237
elif self.status == ResultStatus.FAILED:
236238
return (
237-
exception_from_dict(cast(dict, self._result))
239+
exception_from_dict(cast(SerializedExceptionDict, self._result))
238240
if self._result is not None
239241
else None
240242
)
241243

242244
raise ValueError("Task has not finished yet")
243245

246+
@property
247+
def traceback(self) -> Optional[str]:
248+
"""
249+
Return the string representation of the traceback of the task if it failed
250+
"""
251+
if self.status == ResultStatus.FAILED and self._result is not None:
252+
return cast(SerializedExceptionDict, self._result)["exc_traceback"]
253+
254+
return None
255+
244256
def get_result(self) -> Optional[T]:
245257
"""
246258
A convenience method to get the result, or None if it's not ready yet or has failed.

django_tasks/utils.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,25 @@
33
import time
44
from collections import deque
55
from functools import wraps
6-
from typing import Any, Callable, TypeVar
6+
from traceback import format_exception
7+
from typing import Any, Callable, List, TypedDict, TypeVar
78

89
from django.utils.module_loading import import_string
910
from typing_extensions import ParamSpec
1011

12+
13+
class SerializedExceptionDict(TypedDict):
14+
"""Type for the dictionary holding exception informations in task result
15+
16+
The task result either stores the result of the task, or the serialized exception
17+
information required to reconstitute part of the exception for debugging.
18+
"""
19+
20+
exc_type: str
21+
exc_args: List[Any]
22+
exc_traceback: str
23+
24+
1125
T = TypeVar("T")
1226
P = ParamSpec("P")
1327

@@ -71,14 +85,15 @@ def get_module_path(val: Any) -> str:
7185
return f"{val.__module__}.{val.__qualname__}"
7286

7387

74-
def exception_to_dict(exc: BaseException) -> dict:
88+
def exception_to_dict(exc: BaseException) -> SerializedExceptionDict:
7589
return {
7690
"exc_type": get_module_path(type(exc)),
7791
"exc_args": json_normalize(exc.args),
92+
"exc_traceback": "".join(format_exception(type(exc), exc, exc.__traceback__)),
7893
}
7994

8095

81-
def exception_from_dict(exc_data: dict) -> BaseException:
96+
def exception_from_dict(exc_data: SerializedExceptionDict) -> BaseException:
8297
exc_class = import_string(exc_data["exc_type"])
8398

8499
if not inspect.isclass(exc_class) or not issubclass(exc_class, BaseException):

tests/tests/test_database_backend.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,10 @@ def test_failing_task(self) -> None:
343343
self.assertGreaterEqual(result.started_at, result.enqueued_at) # type: ignore
344344
self.assertGreaterEqual(result.finished_at, result.started_at) # type: ignore
345345
self.assertEqual(result.status, ResultStatus.FAILED)
346+
346347
self.assertIsInstance(result.result, ValueError)
348+
assert result.traceback # So that mypy knows the next line is allowed
349+
self.assertTrue(result.traceback.endswith("ValueError: This task failed\n"))
347350

348351
self.assertEqual(DBTaskResult.objects.ready().count(), 0)
349352

@@ -364,7 +367,9 @@ def test_complex_exception(self) -> None:
364367
self.assertGreaterEqual(result.started_at, result.enqueued_at) # type: ignore
365368
self.assertGreaterEqual(result.finished_at, result.started_at) # type: ignore
366369
self.assertEqual(result.status, ResultStatus.FAILED)
370+
367371
self.assertIsNone(result.result)
372+
self.assertIsNone(result.traceback)
368373

369374
self.assertEqual(DBTaskResult.objects.ready().count(), 0)
370375

tests/tests/test_immediate_backend.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ def test_catches_exception(self) -> None:
5858
self.assertGreaterEqual(result.started_at, result.enqueued_at)
5959
self.assertGreaterEqual(result.finished_at, result.started_at)
6060
self.assertIsInstance(result.result, ValueError)
61+
self.assertTrue(result.traceback.endswith("ValueError: This task failed\n"))
6162
self.assertIsNone(result.get_result())
6263
self.assertEqual(result.task, test_tasks.failing_task)
6364
self.assertEqual(result.args, [])
@@ -72,7 +73,10 @@ def test_complex_exception(self) -> None:
7273
self.assertIsNotNone(result.finished_at)
7374
self.assertGreaterEqual(result.started_at, result.enqueued_at)
7475
self.assertGreaterEqual(result.finished_at, result.started_at)
76+
7577
self.assertIsNone(result.result)
78+
self.assertIsNone(result.traceback)
79+
7680
self.assertIsNone(result.get_result())
7781
self.assertEqual(result.task, test_tasks.complex_exception)
7882
self.assertEqual(result.args, [])

tests/tests/test_utils.py

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import datetime
2+
import hashlib
3+
import optparse
24
import subprocess
5+
from typing import List
36
from unittest.mock import Mock
47

58
from django.core.exceptions import ImproperlyConfigured
@@ -101,20 +104,75 @@ def test_serialize_exceptions(self) -> None:
101104
with self.subTest(exc):
102105
data = utils.exception_to_dict(exc)
103106
self.assertEqual(utils.json_normalize(data), data)
104-
self.assertEqual(set(data.keys()), {"exc_type", "exc_args"})
105-
reconstructed = utils.exception_from_dict(data)
106-
self.assertIsInstance(reconstructed, type(exc))
107-
self.assertEqual(reconstructed.args, exc.args)
107+
self.assertEqual(
108+
set(data.keys()), {"exc_type", "exc_args", "exc_traceback"}
109+
)
110+
exception = utils.exception_from_dict(data)
111+
self.assertIsInstance(exception, type(exc))
112+
self.assertEqual(exception.args, exc.args)
113+
114+
# Check that the exception traceback contains a minimal traceback
115+
msg = str(exc.args[0]) if exc.args else ""
116+
traceback = data["exc_traceback"]
117+
self.assertIn(exc.__class__.__name__, traceback)
118+
self.assertIn(msg, traceback)
119+
120+
def test_serialize_full_traceback(self) -> None:
121+
try:
122+
# Using optparse to generate an error because:
123+
# - it's pure python
124+
# - it's easy to trip down
125+
# - it's unlikely to change ever
126+
optparse.OptionParser(option_list=[1]) # type: ignore
127+
except Exception as e:
128+
traceback = utils.exception_to_dict(e)["exc_traceback"]
129+
# The test is willingly fuzzy to ward against changes in the
130+
# traceback formatting
131+
self.assertIn("traceback", traceback.lower())
132+
self.assertIn("line", traceback.lower())
133+
self.assertIn(optparse.__file__, traceback)
134+
self.assertTrue(
135+
traceback.endswith("TypeError: not an Option instance: 1\n")
136+
)
137+
138+
def test_serialize_traceback_from_c_module(self) -> None:
139+
try:
140+
# Same as test_serialize_full_traceback, but uses hashlib
141+
# because it's in C, not in Python
142+
hashlib.md5(1) # type: ignore
143+
except Exception as e:
144+
traceback = utils.exception_to_dict(e)["exc_traceback"]
145+
self.assertIn("traceback", traceback.lower())
146+
self.assertTrue(
147+
traceback.endswith(
148+
"TypeError: object supporting the buffer API required\n"
149+
)
150+
)
151+
self.assertIn("hashlib.md5(1)", traceback)
108152

109153
def test_cannot_deserialize_non_exception(self) -> None:
110-
for data in [
111-
{"exc_type": "subprocess.check_output", "exc_args": ["exit", "1"]},
112-
{"exc_type": "True", "exc_args": []},
113-
{"exc_type": "math.pi", "exc_args": []},
114-
{"exc_type": __name__, "exc_args": []},
115-
{"exc_type": utils.get_module_path(type(self)), "exc_args": []},
116-
{"exc_type": utils.get_module_path(Mock), "exc_args": []},
117-
]:
154+
serialized_exceptions: List[utils.SerializedExceptionDict] = [
155+
{
156+
"exc_type": "subprocess.check_output",
157+
"exc_args": ["exit", "1"],
158+
"exc_traceback": "",
159+
},
160+
{"exc_type": "True", "exc_args": [], "exc_traceback": ""},
161+
{"exc_type": "math.pi", "exc_args": [], "exc_traceback": ""},
162+
{"exc_type": __name__, "exc_args": [], "exc_traceback": ""},
163+
{
164+
"exc_type": utils.get_module_path(type(self)),
165+
"exc_args": [],
166+
"exc_traceback": "",
167+
},
168+
{
169+
"exc_type": utils.get_module_path(Mock),
170+
"exc_args": [],
171+
"exc_traceback": "",
172+
},
173+
]
174+
175+
for data in serialized_exceptions:
118176
with self.subTest(data):
119177
with self.assertRaises((TypeError, ImportError)):
120178
utils.exception_from_dict(data)

0 commit comments

Comments
 (0)