Skip to content

Commit 55c61ef

Browse files
Raise the real import error when a thing cannot import
1 parent 521aae0 commit 55c61ef

File tree

3 files changed

+74
-24
lines changed

3 files changed

+74
-24
lines changed

src/labthings_fastapi/server/cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727

2828
from . import ThingServer
2929
from . import fallback
30-
from .config_model import ThingServerConfig
30+
from .config_model import ThingServerConfig, ThingImportFailure
3131

3232

3333
def get_default_parser() -> ArgumentParser:
@@ -156,7 +156,7 @@ def serve_from_cli(
156156
app.labthings_error = e
157157
uvicorn.run(app, host=args.host, port=args.port)
158158
else:
159-
if isinstance(e, ValidationError):
159+
if isinstance(e, (ValidationError, ThingImportFailure)):
160160
print(f"Error reading LabThings configuration:\n{e}")
161161
sys.exit(3)
162162
else:

src/labthings_fastapi/server/config_model.py

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

9+
from importlib import import_module
10+
import re
911
from pydantic import (
1012
BaseModel,
1113
Field,
1214
ImportString,
1315
AliasChoices,
1416
field_validator,
15-
ValidationError,
1617
ValidatorFunctionWrapHandler,
1718
WrapValidator,
1819
)
19-
from pydantic_core import PydanticCustomError
2020
from typing import Any, Annotated, TypeAlias
2121
from collections.abc import Mapping, Sequence, Iterable
2222

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+
28+
29+
class ThingImportFailure(BaseException):
30+
"""Failed to import Thing. Raise with import traceback."""
31+
2332

2433
def contain_import_errors(value: Any, handler: ValidatorFunctionWrapHandler) -> Any:
2534
"""Prevent errors during import from causing odd validation errors.
@@ -39,17 +48,35 @@ def contain_import_errors(value: Any, handler: ValidatorFunctionWrapHandler) ->
3948
"""
4049
try:
4150
return handler(value)
42-
except Exception as e:
43-
# ImportErrors get wrapped as ValidationErrors, and that's fine:
44-
# it results in a sensible error message.
45-
if isinstance(e, ValidationError):
46-
if all(err["type"] == "import_error" for err in e.errors()):
47-
raise
48-
raise PydanticCustomError(
49-
"import_error",
50-
"An exception was raised when importing '{name}'.",
51-
{"name": str(value), "error": e},
52-
) from e
51+
except Exception:
52+
# In the case where this is a matching import rule.
53+
if isinstance(value, str) and IMPORT_REGEX.match(value):
54+
# Try to import the module again
55+
module_name = value.split(":")[0]
56+
thing_name = value.split(":")[1]
57+
try:
58+
module = import_module(module_name)
59+
except Exception as import_err: # noqa: BLE001
60+
# Capture the import exception and raise as a ThingImportFailure which
61+
# is a subclass of BaseException.
62+
msg = f"[{type(import_err).__name__}] {import_err}"
63+
exc = ThingImportFailure(msg)
64+
# Raise from None so the traceback is just the clear import traceback.
65+
raise exc.with_traceback(import_err.__traceback__) from None
66+
67+
# If check the Thing is there and if not raise the ThingImportFailure
68+
# wrapping an ImportError.
69+
if not hasattr(module, thing_name):
70+
msg = (
71+
f"[ImportError] cannot import name '{thing_name}' from "
72+
f"'{module_name}'"
73+
)
74+
# Raise from None so the traceback is just the clear import traceback.
75+
raise ThingImportFailure(msg) from None
76+
77+
# If this was the wrong type, didn't match the regex, or somehow imported fine
78+
# then re-raise the original error.
79+
raise
5380

5481

5582
ThingImportString = Annotated[

tests/test_server_config_model.py

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
from pydantic import ValidationError
44
import pytest
5-
from labthings_fastapi.server.config_model import ThingConfig, ThingServerConfig
5+
from labthings_fastapi.server.config_model import (
6+
ThingConfig,
7+
ThingServerConfig,
8+
ThingImportFailure,
9+
)
610
import labthings_fastapi.example_things
711
from labthings_fastapi.example_things import MyThing
812

@@ -40,7 +44,6 @@ def test_ThingConfig():
4044
{},
4145
{"foo": "bar"},
4246
{"class": MyThing, "kwargs": 1},
43-
"missing.module:object",
4447
4,
4548
None,
4649
False,
@@ -98,22 +101,42 @@ def test_ThingServerConfig():
98101
ThingServerConfig(things={name: MyThing})
99102

100103

101-
def test_unimportable_module():
104+
def test_unimportable_modules():
102105
"""Test that unimportable modules raise errors as expected."""
103-
expected_message = "exception was raised when importing 'tests.unimportable"
104-
with pytest.raises(ValidationError, match=expected_message):
106+
107+
with pytest.raises(
108+
ThingImportFailure, match="\[ModuleNotFoundError\] No module named 'missing'"
109+
):
110+
ThingConfig(cls="missing.module:object")
111+
112+
with pytest.raises(
113+
ThingImportFailure,
114+
match="\[RuntimeError\] This module should not be importable!",
115+
):
105116
# This checks RuntimErrors get reported with a single error
106117
ThingConfig(cls="tests.unimportable.runtimeerror:SomeClass")
107-
with pytest.raises(ValidationError, match=expected_message):
118+
119+
with pytest.raises(
120+
ThingImportFailure,
121+
match="\[ValueError\] This module should not be importable due to ValueError!",
122+
):
108123
# This checks ValueErrors get reported with a single error
109124
# rather than getting swallowed by a ValidationError
110125
ThingConfig(cls="tests.unimportable.valueerror:SomeClass")
126+
111127
with pytest.raises(
112-
ValidationError,
113-
match="No module named 'tests.unimportable.missing'",
128+
ThingImportFailure,
129+
match="\[ModuleNotFoundError\] No module named 'tests.unimportable.missing'",
114130
):
115131
# This checks normal ImportErrors get reported as usual
116132
ThingConfig(cls="tests.unimportable.missing:SomeClass")
117-
with pytest.raises(ValidationError, match="cannot import name 'MissingClass'"):
133+
134+
with pytest.raises(
135+
ThingImportFailure,
136+
match=(
137+
"\[ImportError\] cannot import name 'MissingClass' from "
138+
"'tests.unimportable'"
139+
),
140+
):
118141
# This checks normal ImportErrors get reported as usual
119142
ThingConfig(cls="tests.unimportable:MissingClass")

0 commit comments

Comments
 (0)