Skip to content

Improve ImportString error when internal imports fail - #12740

Merged
davidhewitt merged 8 commits into
pydantic:mainfrom
tsembp:fix-importstring-internal-importerror
Feb 5, 2026
Merged

davidhewitt merged 8 commits into
pydantic:mainfrom
tsembp:fix-importstring-internal-importerror

Conversation

@tsembp

@tsembp tsembp commented Jan 25, 2026 •

Copy link
Copy Markdown
Contributor

Change Summary

What this PR does

Fixes #12715.

Improves the error handling of ImportString so that internal import failures
(e.g. missing dependencies inside a module) are no longer masked as
ImportError: No module named ....

If the missing module is not the requested import path (or its submodules),
the original ModuleNotFoundError is now correctly re-raised.

Why

Previously, ImportString could incorrectly report that a module did not exist,
even when the real issue was a missing internal dependency. This made debugging
harder and hid the true cause of the failure.

Changes

  • Detect whether ModuleNotFoundError.name refers to the requested module
  • Re-raise internal import errors instead of masking them
  • Added tests covering the new behavior

Tests

  • Added new tests in tests/test_types.py
  • All existing tests pass locally

Related issue number

Checklist

  • The pull request title is a good summary of the changes - it will be used in the changelog
  • Unit tests for the changes exist
  • Tests pass on CI
  • Documentation reflects the changes where applicable
  • My PR is ready to review, please add a comment including the phrase "please review" to assign reviewers

Selected Reviewer: @Viicos

@tsembp

tsembp commented Jan 25, 2026

Copy link
Copy Markdown
Contributor Author

please review

@github-actions github-actions Bot added the relnotes-fix Used for bugfixes. label Jan 25, 2026
@codspeed

codspeed Bot commented Jan 25, 2026 •

Copy link
Copy Markdown

CodSpeed Performance Report

Merging this PR will improve performance by 6.66%

Comparing tsembp:fix-importstring-internal-importerror (0a18c38) with main (c42224a)

Summary

⚡ 1 improved benchmark
✅ 211 untouched benchmarks

Performance Changes

Benchmark BASE HEAD Efficiency
⚡ test_list_of_ints_core_json 780.4 µs 731.7 µs +6.66%

@tsembp

tsembp commented Jan 30, 2026

Copy link
Copy Markdown
Contributor Author

Hi @Viicos , just a quick follow-up in case this slipped through.

No rush at all, but happy to make any adjustments or add tests if needed.
Thanks!

Comment thread pydantic/_internal/_validators.py Outdated
Comment on lines 116 to 120
@@ -115,8 +121,7 @@ def _import_string_logic(dotted_path: str) -> Any:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Is there a possible bug here, where if len(components) == 2 we already had a :attribute which we're discarding and ignoring if line 120 returns?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, thanks for pointing this out.

You’re right, in the case where the input already includes a :attribute, the fallback path here could incorrectly discard it. That wasn’t intentional.

I’ll adjust the logic to preserve the original attribute when : is already present and add a test to cover this case.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No worries, I think this bug existed before your diff, however would be good to fix as part of the adjustments here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hello again @davidhewitt !
I just made changes in the code snippet that should cover the case you pointed out and also added a test for that as well.
Let me know I need to adjust something further.

@davidhewitt davidhewitt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, some suggestions to simplify / refine.

Comment thread pydantic/_internal/_validators.py Outdated
raise ImportError(f"Import strings should have at most one ':'; received {dotted_path!r}")


has_explicit_attr = len(components) == 2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would prefer to write this as

Suggested change
has_explicit_attr = len(components) == 2
attribute = None:
if len(components) == 2:
attribute = components[1]

Comment thread pydantic/_internal/_validators.py Outdated
Comment on lines 127 to 128
if len(components) > 1:
attribute = components[1]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With the other change to set attribute above,

Suggested change
if len(components) > 1:
attribute = components[1]
if attribute is not None:

Comment thread pydantic/_internal/_validators.py Outdated
if missing and not (missing == module_path or missing.startswith(module_path + ".")):
raise

if not has_explicit_attr and '.' in module_path:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

With the other change to set attribute above,

Suggested change
if not has_explicit_attr and '.' in module_path:
if attribute is None and '.' in module_path:

Comment thread pydantic/_internal/_validators.py Outdated
return _import_string_logic(f'{maybe_module_path}:{maybe_attribute}')
except ImportError:
pass
raise ImportError(f'No module named {module_path!r}') from e

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this error is the real cause of your reported bug, let's just remove it and let e say what's going on.

Suggested change
raise ImportError(f'No module named {module_path!r}') from e

Comment thread pydantic/_internal/_validators.py Outdated
Comment on lines +111 to +115
# If the missing module is NOT the module we tried to import (or its submodule),
# then it's an internal import failure (missing dependency) and we must not mask it.
missing = getattr(e, "name", None)
if missing and not (missing == module_path or missing.startswith(module_path + ".")):
raise

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think if the raise ImportError(...) on line 125 is removed as I suggest, we won't need this.

Suggested change
# If the missing module is NOT the module we tried to import (or its submodule),
# then it's an internal import failure (missing dependency) and we must not mask it.
missing = getattr(e, "name", None)
if missing and not (missing == module_path or missing.startswith(module_path + ".")):
raise

Comment thread tests/test_types.py Outdated
Comment on lines +6506 to +6513
with pytest.raises(ValidationError) as exc_info:
adapter.validate_python('my_module.MyClass')

msg = str(exc_info.value)
assert "definitely_missing_dep_xyz" in msg
assert "my_module" in msg
# ensure we don't incorrectly claim the object path is missing
assert "my_module.MyClass" not in msg or "No module named 'my_module.MyClass'" not in msg

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think asserting the underlying message should be good enough

Suggested change
with pytest.raises(ValidationError) as exc_info:
adapter.validate_python('my_module.MyClass')
msg = str(exc_info.value)
assert "definitely_missing_dep_xyz" in msg
assert "my_module" in msg
# ensure we don't incorrectly claim the object path is missing
assert "my_module.MyClass" not in msg or "No module named 'my_module.MyClass'" not in msg
with pytest.raises(ValidationError, match="No module named 'definitely_missing_dep_xyz'") as exc_info:
adapter.validate_python('my_module.MyClass')

Comment thread tests/test_types.py Outdated
Comment on lines +6516 to +6521
def test_import_string_explicit_colon_does_not_try_dot_fallback():
# Regression test: if the input already contains ':attr', we should NOT try
# to reinterpret dots as module/attribute splits (which could accidentally
# create an invalid import string containing two colons).
with pytest.raises(ModuleNotFoundError):
_import_string_logic("does.not.exist:Thing")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please use a TypeAdapter in this test too, rather than the internal function. I also think we should probably use an import string like 'collections.defaultdict:get' which a buggy implementation might actually resolve, rather than a truly nonexistent path.

The removal of custom error masking now allows Python's import
system to provide more detailed error messages (e.g.,
"'os' is not a package" instead of just "No module named").
Updated tests to match the new, more informative error output.
@tsembp

tsembp commented Feb 2, 2026

Copy link
Copy Markdown
Contributor Author

Hello @davidhewitt. I've applied all your suggested changes:

  • Changed has_explicit_attr to attribute = None pattern
  • Updated conditions to use attribute is None / attribute is not None
  • Removed the error-masking raise ImportError(...) line
  • Removed the internal import detection block (no longer needed)
  • Simplified test assertions using match parameter
  • Updated test_import_string_explicit_colon_does_not_try_dot_fallback to use TypeAdapter and collections.defaultdict:get

All tests passing locally (949 passed). The error messages are now more informative. They include context from Python's import system like 'os' is not a package which helps users understand why imports fail.

Ready for another review when you have time. Thanks again for your feedback!

@davidhewitt

Copy link
Copy Markdown
Contributor

Thanks, looks good, please fix the issues with lint and CI and then this ready to merge.

@github-actions

github-actions Bot commented Feb 2, 2026

Copy link
Copy Markdown
Contributor

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  pydantic/_internal
  _validators.py
Project Total  

This report was generated by python-coverage-comment-action

@tsembp

tsembp commented Feb 2, 2026

Copy link
Copy Markdown
Contributor Author

@davidhewitt all lint and CI issues are now fixed and all checks are passing.
Happy to make any final tweaks, otherwise this should be ready to merge. :)

@davidhewitt davidhewitt left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks, I am happy with this!

@davidhewitt
davidhewitt merged commit a2934a1 into pydantic:main Feb 5, 2026
67 checks passed
@tsembp

tsembp commented Feb 5, 2026

Copy link
Copy Markdown
Contributor Author

Thank you very much!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improve logic around ImportString validation when the ImportError originates from another source

3 participants