Skip to content

Commit 521aae0

Browse files
committed
Handle import errors more cleanly
This uses a WrapValidator to improve the handling of errors in an ImportString. The problem, identified in #222, is that `TypeError` and `ValueError` are treated specially by `pydantic`. This means that if either of those exceptions are raised during module import, we get a confusing validation error. My wrap validator ensures that we get either a sensible import-related error (e.g. moule not found, name not found in module) or a single clear error stating that an error occurred during module import. This does not dump a traceback, because doing so during validation tends to lead to very confusing messages. However, it should make it obvious that the problem is an unimportable module, and point someone in the right direction.
1 parent 05b2a8e commit 521aae0

File tree

5 files changed

+101
-14
lines changed

5 files changed

+101
-14
lines changed

src/labthings_fastapi/server/config_model.py

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

9-
from pydantic import BaseModel, Field, ImportString, AliasChoices, field_validator
9+
from pydantic import (
10+
BaseModel,
11+
Field,
12+
ImportString,
13+
AliasChoices,
14+
field_validator,
15+
ValidationError,
16+
ValidatorFunctionWrapHandler,
17+
WrapValidator,
18+
)
19+
from pydantic_core import PydanticCustomError
1020
from typing import Any, Annotated, TypeAlias
1121
from collections.abc import Mapping, Sequence, Iterable
1222

1323

24+
def contain_import_errors(value: Any, handler: ValidatorFunctionWrapHandler) -> Any:
25+
"""Prevent errors during import from causing odd validation errors.
26+
27+
This is used to wrap the pydantic ImportString validator, and ensures that any
28+
module that won't import shows up with a single clear error.
29+
30+
:param value: The value being validated.
31+
:param handler: The validator handler.
32+
33+
:return: The validated value.
34+
35+
:raises PydanticCustomError: if an import error occurs.
36+
:raises Exception: if the ImportString logic raises a ValidationError
37+
containing only import errors, this will be re-raised. All other
38+
exceptions are wrapped in a PydanticCustomError.
39+
"""
40+
try:
41+
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
53+
54+
55+
ThingImportString = Annotated[
56+
ImportString,
57+
WrapValidator(contain_import_errors),
58+
]
59+
60+
1461
# The type: ignore below is a spurious warning about `kwargs`.
1562
# see https://github.com/pydantic/pydantic/issues/3125
1663
class ThingConfig(BaseModel): # type: ignore[no-redef]
1764
r"""The information needed to add a `.Thing` to a `.ThingServer`\ ."""
1865

19-
cls: ImportString = Field(
66+
cls: ThingImportString = Field(
2067
validation_alias=AliasChoices("cls", "class"),
2168
description="The Thing subclass to add to the server.",
2269
)
@@ -51,7 +98,7 @@ class ThingConfig(BaseModel): # type: ignore[no-redef]
5198
]
5299

53100

54-
ThingsConfig: TypeAlias = Mapping[ThingName, ThingConfig | ImportString]
101+
ThingsConfig: TypeAlias = Mapping[ThingName, ThingConfig | ThingImportString]
55102

56103

57104
class ThingServerConfig(BaseModel):

tests/test_server_config_model.py

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

33
from pydantic import ValidationError
44
import pytest
5-
from labthings_fastapi.server import config_model as cm
5+
from labthings_fastapi.server.config_model import ThingConfig, ThingServerConfig
66
import labthings_fastapi.example_things
77
from labthings_fastapi.example_things import MyThing
88

99

1010
def test_ThingConfig():
1111
"""Test the ThingConfig model loads classes as expected."""
1212
# We should be able to create a valid config with just a class
13-
direct = cm.ThingConfig(cls=labthings_fastapi.example_things.MyThing)
13+
direct = ThingConfig(cls=labthings_fastapi.example_things.MyThing)
1414
# Equivalently, we should be able to pass a string
15-
fromstr = cm.ThingConfig(cls="labthings_fastapi.example_things:MyThing")
15+
fromstr = ThingConfig(cls="labthings_fastapi.example_things:MyThing")
1616
assert direct.cls is MyThing
1717
assert fromstr.cls is MyThing
1818
# In the absence of supplied arguments, default factories should be used
@@ -21,14 +21,14 @@ def test_ThingConfig():
2121
assert direct.thing_slots == {}
2222

2323
with pytest.raises(ValidationError, match="No module named"):
24-
cm.ThingConfig(cls="missing.module")
24+
ThingConfig(cls="missing.module")
2525

2626

2727
VALID_THING_CONFIGS = {
2828
"direct": MyThing,
2929
"string": "labthings_fastapi.example_things:MyThing",
30-
"model_d": cm.ThingConfig(cls=MyThing),
31-
"model_s": cm.ThingConfig(cls="labthings_fastapi.example_things:MyThing"),
30+
"model_d": ThingConfig(cls=MyThing),
31+
"model_s": ThingConfig(cls="labthings_fastapi.example_things:MyThing"),
3232
"dict_d": {"cls": MyThing},
3333
"dict_da": {"class": MyThing},
3434
"dict_s": {"cls": "labthings_fastapi.example_things:MyThing"},
@@ -71,28 +71,49 @@ def test_ThingConfig():
7171
def test_ThingServerConfig():
7272
"""Check validation of the whole server config."""
7373
# Things should be able to be specified as a string, a class, or a ThingConfig
74-
config = cm.ThingServerConfig(things=VALID_THING_CONFIGS)
74+
config = ThingServerConfig(things=VALID_THING_CONFIGS)
7575
assert len(config.thing_configs) == 8
7676
for v in config.thing_configs.values():
7777
assert v.cls is MyThing
7878

7979
# When we validate from a dict, the same options work
80-
config = cm.ThingServerConfig.model_validate({"things": VALID_THING_CONFIGS})
80+
config = ThingServerConfig.model_validate({"things": VALID_THING_CONFIGS})
8181
assert len(config.thing_configs) == 8
8282
for v in config.thing_configs.values():
8383
assert v.cls is MyThing
8484

8585
# Check invalid configs are picked up
8686
for spec in INVALID_THING_CONFIGS:
8787
with pytest.raises(ValidationError):
88-
cm.ThingServerConfig(things={"thing": spec})
88+
ThingServerConfig(things={"thing": spec})
8989

9090
# Check valid names are allowed
9191
for name in VALID_THING_NAMES:
92-
sc = cm.ThingServerConfig(things={name: MyThing})
92+
sc = ThingServerConfig(things={name: MyThing})
9393
assert sc.thing_configs[name].cls is MyThing
9494

9595
# Check bad names raise errors
9696
for name in INVALID_THING_NAMES:
9797
with pytest.raises(ValidationError):
98-
cm.ThingServerConfig(things={name: MyThing})
98+
ThingServerConfig(things={name: MyThing})
99+
100+
101+
def test_unimportable_module():
102+
"""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):
105+
# This checks RuntimErrors get reported with a single error
106+
ThingConfig(cls="tests.unimportable.runtimeerror:SomeClass")
107+
with pytest.raises(ValidationError, match=expected_message):
108+
# This checks ValueErrors get reported with a single error
109+
# rather than getting swallowed by a ValidationError
110+
ThingConfig(cls="tests.unimportable.valueerror:SomeClass")
111+
with pytest.raises(
112+
ValidationError,
113+
match="No module named 'tests.unimportable.missing'",
114+
):
115+
# This checks normal ImportErrors get reported as usual
116+
ThingConfig(cls="tests.unimportable.missing:SomeClass")
117+
with pytest.raises(ValidationError, match="cannot import name 'MissingClass'"):
118+
# This checks normal ImportErrors get reported as usual
119+
ThingConfig(cls="tests.unimportable:MissingClass")

tests/unimportable/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""A collection of submodules that can't be imported.
2+
3+
This is to help test ImportString error handling.
4+
See test_server_config_model.py.
5+
"""

tests/unimportable/runtimeerror.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""A module that can't be imported due to a runtimeerror.
2+
3+
This is to help test ImportString error handling.
4+
See test_server_config_model.py.
5+
"""
6+
7+
raise RuntimeError("This module should not be importable!")

tests/unimportable/valueerror.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""This module raises a ValueError when imported.
2+
3+
This is to help test ImportString error handling.
4+
See test_server_config_model.py.
5+
"""
6+
7+
raise ValueError("This module should not be importable due to ValueError!")

0 commit comments

Comments
 (0)