Skip to content

Commit

Permalink
feat: add module loading utilities
Browse files Browse the repository at this point in the history
Add utilities to load modules, types, and objects from dotted path strings.
  • Loading branch information
kennedykori committed Sep 14, 2023
1 parent 1b620d5 commit bd6fb19
Show file tree
Hide file tree
Showing 5 changed files with 220 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
("py:class", "sghi.utils.checkers._Comparable"), # private type annotations
("py:class", "sghi.utils.checkers._ST"), # private type annotations
("py:class", "sghi.utils.checkers._T"), # private type annotations
("py:class", "sghi.utils.module_loading._T"), # private type annotations
("py:class", "sghi.typing._CT_contra"), # private type annotations
("py:obj", "sghi.disposable.decorators.not_disposed._P"), # private type annotations
("py:obj", "sghi.disposable.decorators.not_disposed._R"), # private type annotations
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ classifiers = [
"Typing :: Typed"
]
dependencies = [
"importlib-metadata>=6.8.0",
"typing-extensions>=4.7",
]
description = "A collection of utilities used throughout SGHI's Python projects."
Expand Down
3 changes: 3 additions & 0 deletions src/sghi/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
ensure_not_none_nor_empty,
ensure_predicate,
)
from .module_loading import import_string, import_string_as_klass
from .others import future_succeeded, type_fqn

__all__ = [
Expand All @@ -22,5 +23,7 @@
"ensure_not_none_nor_empty",
"ensure_predicate",
"future_succeeded",
"import_string",
"import_string_as_klass",
"type_fqn",
]
100 changes: 100 additions & 0 deletions src/sghi/utils/module_loading.py
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)
115 changes: 115 additions & 0 deletions test/sghi/utils_tests/module_loading_tests.py
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)

0 comments on commit bd6fb19

Please sign in to comment.