-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add utilities to load modules, types, and objects from dotted path strings.
- Loading branch information
1 parent
1b620d5
commit bd6fb19
Showing
5 changed files
with
220 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,100 @@ | ||
""" | ||
Utilities to load modules, types, and objects from dotted path strings. | ||
""" | ||
import inspect | ||
from typing import Any, Final, TypeVar, cast | ||
|
||
from importlib_metadata import EntryPoint | ||
|
||
from .checkers import ensure_not_none, ensure_not_none_nor_empty | ||
from .others import type_fqn | ||
|
||
# ============================================================================= | ||
# TYPES | ||
# ============================================================================= | ||
|
||
|
||
_T = TypeVar("_T") | ||
|
||
|
||
# ============================================================================= | ||
# CONSTANTS | ||
# ============================================================================= | ||
|
||
|
||
_UNKNOWN_STR: Final[str] = "UNKNOWN" | ||
|
||
|
||
# ============================================================================= | ||
# IMPORT UTILITIES | ||
# ============================================================================= | ||
|
||
|
||
def import_string(dotted_path: str) -> Any: # noqa: ANN401 | ||
""" | ||
Import a dotted module path and return the Python object designated by | ||
the last name in the path. | ||
The `dotted path` can refer to any "importable" Python object | ||
including modules. It should also conform to the format defined by the | ||
Python packaging conventions. See :doc:`the packaging docs on entry points | ||
<pypackage:specifications/entry-points>` for more information. | ||
Raise :exc:`ImportError` if the import failed. | ||
:param dotted_path: A dotted path to a Python object. This MUST not be | ||
``None`` or empty. | ||
:return: The Python object designated by the last name in the path. | ||
:raises ImportError: If the import fails for some reason. | ||
:raises ValueError: If the given dotted path is ``None`` or empty. | ||
""" | ||
|
||
entry_point = EntryPoint( | ||
name=_UNKNOWN_STR, | ||
group=_UNKNOWN_STR, | ||
value=ensure_not_none_nor_empty( | ||
dotted_path, | ||
"'dotted_path' MUST not be None or empty.", | ||
), | ||
) | ||
try: | ||
return entry_point.load() | ||
except AttributeError as exp: | ||
_err_msg: str = str(exp) | ||
raise ImportError(_err_msg) from exp | ||
|
||
|
||
def import_string_as_klass( | ||
dotted_path: str, | ||
target_klass: type[_T], | ||
) -> type[_T]: | ||
"""Import a dotted module path as the given type. | ||
Raise :exc:`ImportError` if the import failed or a :exc:`TypeError` if the | ||
imported Python object is not of the given type or derived from it. | ||
:param dotted_path: A dotted path to a class. This MUST not be ``None`` or | ||
empty. | ||
:param target_klass: The type that the imported module should have or be | ||
derived from. This MUST not be ``None``. | ||
:return: The class designated by the last name in the path. | ||
:raises ImportError: If the import fails for some reason. | ||
:raises TypeError: If the imported object is not of the given type or | ||
derived from it. | ||
:raises ValueError: If ``dotted_path`` is either ``None`` or empty or | ||
``target_klass`` is ``None``. | ||
""" | ||
ensure_not_none(target_klass, "'target_klass' MUST not be None.") | ||
_module = import_string(dotted_path) | ||
if not inspect.isclass(_module) or not issubclass(_module, target_klass): | ||
err_msg: str = ( | ||
"Invalid value, '{}' does not refer to a valid type or to a " | ||
"subtype of '{}'.".format(dotted_path, type_fqn(target_klass)) | ||
) | ||
raise TypeError(err_msg) | ||
|
||
return cast(type[target_klass], _module) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,115 @@ | ||
from collections.abc import Iterable, Mapping, Sequence | ||
from numbers import Number | ||
from typing import Any, Protocol | ||
|
||
import pytest | ||
|
||
from sghi.disposable import Disposable | ||
from sghi.typing import Comparable | ||
from sghi.utils import import_string, import_string_as_klass, type_fqn | ||
|
||
|
||
def test_import_string_returns_imported_object_on_valid_input() -> None: | ||
""" | ||
:func:`import_string` should return the imported Python object when given a | ||
valid dotted path string. | ||
""" | ||
|
||
assert import_string("pytest") is pytest | ||
assert import_string("sghi.utils:import_string") is import_string | ||
assert import_string("sghi.utils.others:type_fqn") is type_fqn | ||
assert ( | ||
type_fqn(import_string("importlib.metadata:EntryPoint")) == | ||
"importlib.metadata.EntryPoint" | ||
) | ||
|
||
|
||
def test_import_string_fails_on_invalid_input() -> None: | ||
""" | ||
:func:`import_string` should raise an ``ImportError`` when given an invalid | ||
dotted path string. | ||
""" | ||
|
||
invalid_dotted_paths: Iterable[str] = ( | ||
"pytestt", | ||
"sghi.utils.import_string", # 'import_string' is not a module | ||
"sghi.utils:import", # 'import' does not exist | ||
"django.contrib", # Not installed. Hopefully, 🤞. | ||
) | ||
for dotted_path in invalid_dotted_paths: | ||
with pytest.raises(ImportError): | ||
import_string(dotted_path) | ||
|
||
|
||
def test_import_string_fails_on_none_or_empty_input() -> None: | ||
""" | ||
:func:`import_string` should raise a ``ValueError`` when given a ``None`` | ||
or empty dotted string. | ||
""" | ||
|
||
dot_path: str = None # type: ignore | ||
invalid_dotted_paths: Iterable[str] = ( | ||
"", | ||
dot_path, | ||
) | ||
for dotted_path in invalid_dotted_paths: | ||
with pytest.raises(ValueError, match="MUST not be None or empty."): | ||
import_string(dotted_path) | ||
|
||
|
||
def test_import_string_as_klass_returns_imported_object_on_valid_input() -> None: # noqa: E501 | ||
""" | ||
:func:`import_string_as_klass` should return the imported type when given a | ||
valid dotted path string. | ||
""" | ||
isak = import_string_as_klass | ||
|
||
assert isak("builtins:dict", Mapping) is dict | ||
assert isak("sghi.disposable:Disposable", Disposable) is Disposable | ||
assert isak("sghi.typing:Comparable", Protocol) is Comparable | ||
|
||
|
||
def test_import_string_as_klass_fails_on_invalid_dotted_path() -> None: | ||
""" | ||
:func:`import_import_string_as_klass` should raise an ``ImportError`` when | ||
given an invalid dotted path string. | ||
""" | ||
|
||
invalid_dotted_paths: Iterable[str] = ( | ||
"pytestt", | ||
"sghi.typing:Compare", # 'Compare' does not exist | ||
"sghi.utils:Import", # 'Import' does not exist | ||
"django.models:Model", # Not installed. Hopefully, 🤞. | ||
) | ||
for dotted_path in invalid_dotted_paths: | ||
with pytest.raises(ImportError): | ||
import_string_as_klass(dotted_path, type) | ||
|
||
|
||
def test_import_string_as_klass_fails_on_none_inputs() -> None: | ||
""" | ||
:func:`import_import_string_as_klass` should raise a ``Value`` when | ||
either the dotted path string or target type is ``None``. | ||
""" | ||
with pytest.raises(ValueError, match="MUST not be None"): | ||
import_string_as_klass(None, Mapping) # type: ignore | ||
|
||
with pytest.raises(ValueError, match="MUST not be None"): | ||
import_string_as_klass("sghi.typing:Comparable", None) # type: ignore | ||
|
||
|
||
def test_import_string_as_klass_fails_on_wrong_dotted_path_type() -> None: | ||
""" | ||
:func:`import_import_string_as_klass` should raise a ``TypeError`` when | ||
given an invalid dotted path string that doesn't refer to an object of the | ||
given type. | ||
""" | ||
|
||
wrong_inputs: Iterable[tuple[str, type[Any]]] = ( | ||
("builtins:dict", Sequence), | ||
("sghi.disposable:Disposable", Mapping), | ||
("sghi.typing:Comparable", Number), | ||
) | ||
for dotted_path, klass in wrong_inputs: | ||
with pytest.raises(TypeError, match="does not refer to a valid type"): | ||
import_string_as_klass(dotted_path, klass) |