Skip to content

Commit 346faf2

Browse files
feat: add pydantic-compatible import validation and deprecate old utilities
1 parent a0b757a commit 346faf2

2 files changed

Lines changed: 219 additions & 7 deletions

File tree

src/crewai/utilities/import_utils.py

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,29 +2,94 @@
22

33
import importlib
44
from types import ModuleType
5+
from typing import Annotated, Any, TypeAlias
56

7+
from pydantic import AfterValidator, TypeAdapter
8+
from typing_extensions import deprecated
69

10+
11+
@deprecated(
12+
"Not needed when using `crewai.utilities.import_utils.import_and_validate_definition`"
13+
)
714
class OptionalDependencyError(ImportError):
815
"""Exception raised when an optional dependency is not installed."""
916

1017

11-
def require(name: str, *, purpose: str) -> ModuleType:
12-
"""Import a module, raising a helpful error if it's not installed.
18+
@deprecated(
19+
"Use `crewai.utilities.import_utils.import_and_validate_definition` instead."
20+
)
21+
def require(name: str, *, purpose: str, attr: str | None = None) -> ModuleType | Any:
22+
"""Import a module, optionally returning a specific attribute.
1323
1424
Args:
1525
name: The module name to import.
1626
purpose: Description of what requires this dependency.
27+
attr: Optional attribute name to get from the module.
1728
1829
Returns:
19-
The imported module.
30+
The imported module or the specified attribute.
2031
2132
Raises:
2233
OptionalDependencyError: If the module is not installed.
34+
AttributeError: If the specified attribute doesn't exist.
2335
"""
2436
try:
25-
return importlib.import_module(name)
37+
module = importlib.import_module(name)
38+
if attr is not None:
39+
return getattr(module, attr)
40+
return module
2641
except ImportError as exc:
42+
package_name = name.split(".")[0]
2743
raise OptionalDependencyError(
2844
f"{purpose} requires the optional dependency '{name}'.\n"
29-
f"Install it with: uv add {name}"
45+
f"Install it with: uv add {package_name}"
3046
) from exc
47+
except AttributeError as exc:
48+
raise AttributeError(f"Module '{name}' has no attribute '{attr}'") from exc
49+
50+
51+
def validate_import_path(v: str) -> Any:
52+
"""Import and return the class/function from the import path.
53+
54+
Args:
55+
v: Import path string in the format 'module.path.ClassName'.
56+
57+
Returns:
58+
The imported class or function.
59+
60+
Raises:
61+
ValueError: If the import path is malformed or the module cannot be imported.
62+
"""
63+
module_path, _, attr = v.rpartition(".")
64+
if not module_path or not attr:
65+
raise ValueError(f"import_path '{v}' must be of the form 'module.ClassName'")
66+
67+
try:
68+
mod = importlib.import_module(module_path)
69+
except ImportError as exc:
70+
parts = module_path.split(".")
71+
if not parts:
72+
raise ValueError(f"Malformed import path: '{v}'") from exc
73+
package = parts[0]
74+
raise ValueError(
75+
f"Package '{package}' could not be imported. Install it with: uv add {package}"
76+
) from exc
77+
78+
if not hasattr(mod, attr):
79+
raise ValueError(f"Attribute '{attr}' not found in module '{module_path}'")
80+
return getattr(mod, attr)
81+
82+
83+
ImportedDefinition: TypeAlias = Annotated[Any, AfterValidator(validate_import_path)]
84+
adapter = TypeAdapter(ImportedDefinition)
85+
86+
87+
def import_and_validate_definition(v: str) -> Any:
88+
"""Pydantic-compatible function to import a class/function from a string path.
89+
90+
Args:
91+
v: Import path string in the format 'module.path.ClassName'.
92+
Returns:
93+
The imported class or function
94+
"""
95+
return adapter.validate_python(v)

tests/utilities/test_import_utils.py

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
"""Tests for import utilities."""
22

3+
import sys
4+
from unittest.mock import MagicMock, patch
5+
36
import pytest
4-
from unittest.mock import patch
57

6-
from crewai.utilities.import_utils import require, OptionalDependencyError
8+
from crewai.utilities.import_utils import (
9+
OptionalDependencyError,
10+
import_and_validate_definition,
11+
require,
12+
validate_import_path,
13+
)
714

815

916
class TestRequire:
@@ -40,3 +47,143 @@ def test_require_with_import_error(self):
4047
def test_optional_dependency_error_is_import_error(self):
4148
"""Test that OptionalDependencyError is a subclass of ImportError."""
4249
assert issubclass(OptionalDependencyError, ImportError)
50+
51+
def test_require_with_attr(self):
52+
"""Test requiring a specific attribute from a module."""
53+
loads = require("json", purpose="testing", attr="loads")
54+
import json
55+
56+
assert loads == json.loads
57+
58+
def test_require_with_nonexistent_attr(self):
59+
"""Test requiring a nonexistent attribute raises AttributeError."""
60+
with pytest.raises(AttributeError) as exc_info:
61+
require("json", purpose="testing", attr="nonexistent_attr")
62+
63+
assert "Module 'json' has no attribute 'nonexistent_attr'" in str(
64+
exc_info.value
65+
)
66+
67+
def test_require_extracts_package_name(self):
68+
"""Test that require correctly extracts package name from module path."""
69+
with pytest.raises(OptionalDependencyError) as exc_info:
70+
require("some.nested.module.path", purpose="testing")
71+
72+
error_msg = str(exc_info.value)
73+
assert "uv add some" in error_msg
74+
75+
76+
class TestValidateImportPath:
77+
"""Test the validate_import_path function."""
78+
79+
def test_validate_import_path_success(self):
80+
"""Test successful import of a class."""
81+
result = validate_import_path("json.JSONDecoder")
82+
import json
83+
84+
assert result == json.JSONDecoder
85+
86+
def test_validate_import_path_malformed_no_module(self):
87+
"""Test validation with no module path."""
88+
with pytest.raises(ValueError) as exc_info:
89+
validate_import_path("ClassName")
90+
91+
assert "import_path 'ClassName' must be of the form 'module.ClassName'" in str(
92+
exc_info.value
93+
)
94+
95+
def test_validate_import_path_empty_string(self):
96+
"""Test validation with empty string."""
97+
with pytest.raises(ValueError) as exc_info:
98+
validate_import_path("")
99+
100+
assert "import_path '' must be of the form 'module.ClassName'" in str(
101+
exc_info.value
102+
)
103+
104+
def test_validate_import_path_module_not_found(self):
105+
"""Test validation with non-existent module."""
106+
with pytest.raises(ValueError) as exc_info:
107+
validate_import_path("nonexistent_module.ClassName")
108+
109+
error_msg = str(exc_info.value)
110+
assert "Package 'nonexistent_module' could not be imported" in error_msg
111+
assert "uv add nonexistent_module" in error_msg
112+
113+
def test_validate_import_path_attribute_not_found(self):
114+
"""Test validation when attribute doesn't exist in module."""
115+
with pytest.raises(ValueError) as exc_info:
116+
validate_import_path("json.NonExistentClass")
117+
118+
assert "Attribute 'NonExistentClass' not found in module 'json'" in str(
119+
exc_info.value
120+
)
121+
122+
def test_validate_import_path_nested_module(self):
123+
"""Test validation with nested module path."""
124+
result = validate_import_path("unittest.mock.MagicMock")
125+
from unittest.mock import MagicMock
126+
127+
assert result == MagicMock
128+
129+
def test_validate_import_path_extracts_package_name(self):
130+
"""Test that package name is correctly extracted for error message."""
131+
with pytest.raises(ValueError) as exc_info:
132+
validate_import_path("some.nested.module.path.ClassName")
133+
134+
error_msg = str(exc_info.value)
135+
assert "Package 'some' could not be imported" in error_msg
136+
assert "uv add some" in error_msg
137+
138+
139+
class TestImportAndValidateDefinition:
140+
"""Test the import_and_validate_definition function."""
141+
142+
def test_import_and_validate_definition_success(self):
143+
"""Test successful import through Pydantic adapter."""
144+
result = import_and_validate_definition("json.JSONEncoder")
145+
import json
146+
147+
assert result == json.JSONEncoder
148+
149+
def test_import_and_validate_definition_with_function(self):
150+
"""Test importing a function instead of a class."""
151+
result = import_and_validate_definition("json.loads")
152+
import json
153+
154+
assert result == json.loads
155+
156+
def test_import_and_validate_definition_invalid(self):
157+
"""Test that invalid paths raise ValueError."""
158+
with pytest.raises(ValueError) as exc_info:
159+
import_and_validate_definition("InvalidPath")
160+
161+
assert "must be of the form 'module.ClassName'" in str(exc_info.value)
162+
163+
def test_import_and_validate_definition_module_error(self):
164+
"""Test error handling for missing modules."""
165+
with pytest.raises(ValueError) as exc_info:
166+
import_and_validate_definition("missing_package.SomeClass")
167+
168+
error_msg = str(exc_info.value)
169+
assert "Package 'missing_package' could not be imported" in error_msg
170+
assert "uv add missing_package" in error_msg
171+
172+
def test_import_and_validate_definition_attribute_error(self):
173+
"""Test error handling for missing attributes."""
174+
with pytest.raises(ValueError) as exc_info:
175+
import_and_validate_definition("json.MissingClass")
176+
177+
assert "Attribute 'MissingClass' not found in module 'json'" in str(
178+
exc_info.value
179+
)
180+
181+
def test_import_and_validate_definition_with_mock(self):
182+
"""Test that mocked modules work correctly."""
183+
mock_module = MagicMock()
184+
mock_class = MagicMock()
185+
mock_module.MockClass = mock_class
186+
187+
with patch.dict(sys.modules, {"mocked_module": mock_module}):
188+
result = import_and_validate_definition("mocked_module.MockClass")
189+
assert result == mock_class

0 commit comments

Comments
 (0)