Skip to content

Commit e88d55e

Browse files
Raise original import error traceback cached by Pydantic
1 parent 3cb43cb commit e88d55e

File tree

2 files changed

+29
-45
lines changed

2 files changed

+29
-45
lines changed

src/labthings_fastapi/server/config_model.py

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@
66
start servers based on configuration files or strings.
77
"""
88

9-
from importlib import import_module
10-
import re
119
from pydantic import (
1210
BaseModel,
1311
Field,
@@ -16,15 +14,11 @@
1614
field_validator,
1715
ValidatorFunctionWrapHandler,
1816
WrapValidator,
17+
ValidationError,
1918
)
2019
from typing import Any, Annotated, TypeAlias
2120
from collections.abc import Mapping, Sequence, Iterable
2221

23-
PYTHON_EL_RE_STR = r"[a-zA-Z_][a-zA-Z0-9_]*"
24-
IMPORT_REGEX = re.compile(
25-
rf"^{PYTHON_EL_RE_STR}(?:\.{PYTHON_EL_RE_STR})*:{PYTHON_EL_RE_STR}$"
26-
)
27-
2822

2923
class ThingImportFailure(BaseException):
3024
"""Failed to import Thing. Raise with import traceback."""
@@ -51,35 +45,30 @@ def contain_import_errors(value: Any, handler: ValidatorFunctionWrapHandler) ->
5145
"""
5246
try:
5347
return handler(value)
54-
except Exception:
55-
# In the case where this is a matching import rule.
56-
if isinstance(value, str) and IMPORT_REGEX.match(value):
57-
# Try to import the module again
58-
module_name = value.split(":")[0]
59-
thing_name = value.split(":")[1]
60-
try:
61-
module = import_module(module_name)
62-
except Exception as import_err: # noqa: BLE001
63-
# Capture the import exception and raise as a ThingImportFailure which
64-
# is a subclass of BaseException.
65-
msg = f"[{type(import_err).__name__}] {import_err}"
66-
exc = ThingImportFailure(msg)
67-
# Raise from None so the traceback is just the clear import traceback.
68-
raise exc.with_traceback(import_err.__traceback__) from None
69-
70-
# If check the Thing is there and if not raise the ThingImportFailure
71-
# wrapping an ImportError.
72-
if not hasattr(module, thing_name):
73-
msg = (
74-
f"[ImportError] cannot import name '{thing_name}' from "
75-
f"'{module_name}'"
76-
)
77-
# Raise from None so the traceback is just the clear import traceback.
78-
raise ThingImportFailure(msg) from None
79-
80-
# If this was the wrong type, didn't match the regex, or somehow imported fine
81-
# then re-raise the original error.
82-
raise
48+
except Exception as validation_err:
49+
# TypeError and ValueErrors are turned into validation errors. Other errors
50+
# are passed through.
51+
# First reraise other errors directly from None to improve the trace.
52+
if not isinstance(validation_err, ValidationError):
53+
exc = ThingImportFailure(
54+
f"[{type(validation_err).__name__}] {validation_err}"
55+
)
56+
raise exc.with_traceback(validation_err.__traceback__) from None
57+
# If it is a validation errror but we we can't find the source then just raise.
58+
errors = validation_err.errors()
59+
if not (len(errors) > 0 and "ctx" in errors[0] and "error" in errors[0]["ctx"]):
60+
raise
61+
62+
# Finally raise the original error during import if it triggered a validation
63+
# error
64+
orig_err = errors[0]["ctx"]["error"]
65+
if isinstance(orig_err, str):
66+
# If the import failed due to the module/class not being found then it is
67+
# a string so raise it.
68+
raise ThingImportFailure(f"{orig_err}") from None
69+
exc = ThingImportFailure(f"[{type(orig_err).__name__}] {orig_err}")
70+
# Raise from None so the traceback is just the clear import traceback.
71+
raise exc.with_traceback(orig_err.__traceback__) from None
8372

8473

8574
ThingImportString = Annotated[

tests/test_server_config_model.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ def test_ThingConfig():
2424
assert direct.kwargs == {}
2525
assert direct.thing_slots == {}
2626

27-
with pytest.raises(ValidationError, match="No module named"):
27+
with pytest.raises(ThingImportFailure, match="No module named 'missing.module'"):
2828
ThingConfig(cls="missing.module")
2929

3030

@@ -104,9 +104,7 @@ def test_ThingServerConfig():
104104
def test_unimportable_modules():
105105
"""Test that unimportable modules raise errors as expected."""
106106

107-
with pytest.raises(
108-
ThingImportFailure, match="\[ModuleNotFoundError\] No module named 'missing'"
109-
):
107+
with pytest.raises(ThingImportFailure, match="No module named 'missing.module'"):
110108
ThingConfig(cls="missing.module:object")
111109

112110
with pytest.raises(
@@ -126,17 +124,14 @@ def test_unimportable_modules():
126124

127125
with pytest.raises(
128126
ThingImportFailure,
129-
match="\[ModuleNotFoundError\] No module named 'tests.unimportable.missing'",
127+
match="No module named 'tests.unimportable.missing'",
130128
):
131129
# This checks normal ImportErrors get reported as usual
132130
ThingConfig(cls="tests.unimportable.missing:SomeClass")
133131

134132
with pytest.raises(
135133
ThingImportFailure,
136-
match=(
137-
"\[ImportError\] cannot import name 'MissingClass' from "
138-
"'tests.unimportable'"
139-
),
134+
match=("cannot import name 'MissingClass' from 'tests.unimportable'"),
140135
):
141136
# This checks normal ImportErrors get reported as usual
142137
ThingConfig(cls="tests.unimportable:MissingClass")

0 commit comments

Comments
 (0)