Skip to content

Conversation

@julianstirling
Copy link
Contributor

@julianstirling julianstirling commented Dec 15, 2025

This is a fix to better import errors. In the case that there is an import error, it actually imports the module again and raises that as a base exception to bypass pydantic capturing it and continuing. This way the error returned is as expected to the user.

Example

If I recreate the error I have changing one line in the server from:

downsampled_array_factor: int = lt.property(default=2)

to

downsampled_array_factor: int = lt.property(2, default=2)

This is in BaseCamera which is imported multiple times. In better_import_errors this still fails with an unhelpful traceback that does not identify the line, or even really the file:

Traceback (most recent call last):
  File "/home/js3214/Documents/gitstuffs/openflexure-microscope-server/src/openflexure_microscope_server/server/__init__.py", line 93, in serve_from_cli
    server = lt.ThingServer.from_config(config)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/openflexure-microscope-server/.venv/lib/python3.11/site-packages/labthings_fastapi/server/__init__.py", line 111, in from_config
    return cls(**dict(config))
           ^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/openflexure-microscope-server/.venv/lib/python3.11/site-packages/labthings_fastapi/server/__init__.py", line 85, in __init__
    self._config = ThingServerConfig(things=things, settings_folder=settings_folder)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/openflexure-microscope-server/.venv/lib/python3.11/site-packages/pydantic/main.py", line 214, in __init__
    validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
pydantic_core._pydantic_core.ValidationError: 6 validation errors for ThingServerConfig
things.camera.ThingConfig
  Input should be a valid dictionary or instance of ThingConfig [type=model_type, input_value='openflexure_microscope_s...ulation:SimulatedCamera', input_type=str]
    For further information visit https://errors.pydantic.dev/2.10/v/model_type
things.camera.function-wrap[contain_import_errors()]
  An exception was raised when importing 'openflexure_microscope_server.things.camera.simulation:SimulatedCamera'. [type=import_error, input_value='openflexure_microscope_s...ulation:SimulatedCamera', input_type=str]
things.autofocus.ThingConfig
  Input should be a valid dictionary or instance of ThingConfig [type=model_type, input_value='openflexure_microscope_s...utofocus:AutofocusThing', input_type=str]
    For further information visit https://errors.pydantic.dev/2.10/v/model_type
things.autofocus.function-wrap[contain_import_errors()]
  An exception was raised when importing 'openflexure_microscope_server.things.autofocus:AutofocusThing'. [type=import_error, input_value='openflexure_microscope_s...utofocus:AutofocusThing', input_type=str]
things.camera_stage_mapping.ThingConfig
  Input should be a valid dictionary or instance of ThingConfig [type=model_type, input_value='openflexure_microscope_s...pping:CameraStageMapper', input_type=str]
    For further information visit https://errors.pydantic.dev/2.10/v/model_type
things.camera_stage_mapping.function-wrap[contain_import_errors()]
  An exception was raised when importing 'openflexure_microscope_server.things.camera_stage_mapping:CameraStageMapper'. [type=import_error, input_value='openflexure_microscope_s...pping:CameraStageMapper', input_type=str]

With the changes in this PR this becomes

Traceback (most recent call last):
  File "/home/js3214/Documents/gitstuffs/openflexure-microscope-server/src/openflexure_microscope_server/server/__init__.py", line 93, in serve_from_cli
    server = lt.ThingServer.from_config(config)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/labthings-fastapi/src/labthings_fastapi/server/__init__.py", line 111, in from_config
    return cls(**dict(config))
           ^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/labthings-fastapi/src/labthings_fastapi/server/__init__.py", line 85, in __init__
    self._config = ThingServerConfig(things=things, settings_folder=settings_folder)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/openflexure-microscope-server/.venv/lib/python3.11/site-packages/pydantic/main.py", line 214, in __init__
    validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/labthings-fastapi/src/labthings_fastapi/server/config_model.py", line 65, in contain_import_errors
    raise exc.with_traceback(import_err.__traceback__) from None
  File "/home/js3214/Documents/gitstuffs/labthings-fastapi/src/labthings_fastapi/server/config_model.py", line 58, in contain_import_errors
    module = import_module(module_name)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.11/importlib/__init__.py", line 126, in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "", line 1204, in _gcd_import
  File "", line 1176, in _find_and_load
  File "", line 1126, in _find_and_load_unlocked
  File "", line 241, in _call_with_frames_removed
  File "", line 1204, in _gcd_import
  File "", line 1176, in _find_and_load
  File "", line 1147, in _find_and_load_unlocked
  File "", line 690, in _load_unlocked
  File "", line 940, in exec_module
  File "", line 241, in _call_with_frames_removed
  File "/home/js3214/Documents/gitstuffs/openflexure-microscope-server/src/openflexure_microscope_server/things/camera/__init__.py", line 167, in 
    class BaseCamera(lt.Thing):
  File "/home/js3214/Documents/gitstuffs/openflexure-microscope-server/src/openflexure_microscope_server/things/camera/__init__.py", line 267, in BaseCamera
    downsampled_array_factor: int = lt.property(2, default=2)
                                    ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/labthings-fastapi/src/labthings_fastapi/properties.py", line 311, in property
    raise MissingDefaultError(
labthings_fastapi.server.config_model.ThingImportFailure: [MissingDefaultError] A non-callable getter was passed to `property`. Usually,this means the default value was not passed as a keyword argument, which is required.

It is perhaps not perfect. But it does clearly identify the line at fault:

  File "/home/js3214/Documents/gitstuffs/openflexure-microscope-server/src/openflexure_microscope_server/things/camera/__init__.py", line 267, in BaseCamera
    downsampled_array_factor: int = lt.property(2, default=2)
                                    ^^^^^^^^^^^^^^^^^^^^^^^^^

Example 2, Incorrect import

Just to verify that if the string itself if problematic we get a sensible traceback this is me changing

"system": "openflexure_microscope_server.things.system:OpenFlexureSystem",

to (and e is removed in the class name)

"system": "openflexure_microscope_server.things.system:OpenFlxureSystem",

Traceback (most recent call last):
  File "/home/js3214/Documents/gitstuffs/openflexure-microscope-server/src/openflexure_microscope_server/server/__init__.py", line 93, in serve_from_cli
    server = lt.ThingServer.from_config(config)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/labthings-fastapi/src/labthings_fastapi/server/__init__.py", line 111, in from_config
    return cls(**dict(config))
           ^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/labthings-fastapi/src/labthings_fastapi/server/__init__.py", line 85, in __init__
    self._config = ThingServerConfig(things=things, settings_folder=settings_folder)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/openflexure-microscope-server/.venv/lib/python3.11/site-packages/pydantic/main.py", line 214, in __init__
    validated_self = self.__pydantic_validator__.validate_python(data, self_instance=self)
                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/js3214/Documents/gitstuffs/labthings-fastapi/src/labthings_fastapi/server/config_model.py", line 68, in contain_import_errors
    raise ThingImportFailure(f"{orig_err}") from None
labthings_fastapi.server.config_model.ThingImportFailure: cannot import name 'OpenFlxureSystem' from 'openflexure_microscope_server.things.system'

rwb27 and others added 2 commits December 14, 2025 21:25
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.
@julianstirling julianstirling force-pushed the original-import-traceback branch from 55c61ef to 8bd000e Compare December 15, 2025 00:46
@barecheck
Copy link

barecheck bot commented Dec 15, 2025

Barecheck - Code coverage report

Total: 95.1%

Your code coverage diff: 0.01% ▴

Uncovered files and lines
FileLines
src/labthings_fastapi/server/cli.py97, 147, 164

@rwb27
Copy link
Collaborator

rwb27 commented Dec 15, 2025

This seems to duplicate some of the logic that's already in ImportString so that broken module exceptions can be regenerated - I'm not totally sure that makes sense to me, though I do see that it results in the traceback you would have wanted.

I see that the error on the cli is unhelpful, because the traceback points to where the config is loaded. I think it might make sense to handle the validation error so the CLI can nicely tell the operator that the module you're trying to load is broken (without a traceback) and perhaps leave the post mortem for later and/or a log file.

I appreciate it's most important to yank 0.0.12 and get a new version out, so if merging this is the best way to get us there, fair enough. I'd like to revisit this with less time pressure in the future though, if that moment ever comes...

@julianstirling
Copy link
Contributor Author

Duplicating logic is a shame. It would be good if we can extract the traceback from Pydantic, but it seems they record each of the errors to count them but not their tracebacks. I think if we can merge this we can make and MR to improve how it is done. Actually re-rasing outside validation does mean we need to do a lot more understanding of the way pydantic does errors and a lot more testing.

@julianstirling julianstirling force-pushed the original-import-traceback branch from e88d55e to 837c6a7 Compare December 15, 2025 13:19
@julianstirling
Copy link
Contributor Author

So it turns our that pydantic does cache the error objects except for certain errors such as ModuleNotFound which are just strings. So this has been updated to use the tracebacks cached by Pydantic.

@julianstirling julianstirling requested a review from rwb27 December 15, 2025 15:56
Copy link
Collaborator

@rwb27 rwb27 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good - let's get this in and we can think about a more elegant solution in the future. We might decide ImportString isn't for us after all...

@julianstirling julianstirling changed the base branch from better_import_errors to main December 15, 2025 17:51
@julianstirling
Copy link
Contributor Author

Changing base was agreed with @rwb27

@julianstirling julianstirling merged commit 8d4340e into main Dec 15, 2025
14 checks passed
@julianstirling julianstirling deleted the original-import-traceback branch December 15, 2025 17:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants