Source code for concoursetools.importing

# (C) Crown Copyright GCHQ
"""
Functions for dynamically importing from Python modules.
"""
from __future__ import annotations

from collections.abc import Generator, Sequence
from contextlib import contextmanager
import importlib.util
import inspect
from pathlib import Path
import sys
from types import ModuleType
from typing import TypeVar

T = TypeVar("T")


[docs] def import_single_class_from_module(file_path: Path, parent_class: type[T], class_name: str | None = None) -> type[T]: """ Import the resource class from the module. Similar to :func:`import_classes_from_module`, but ensures only one class is returned. :param file_path: The location of the module as a file path. :param class_name: The name of the class to extract. Required if multiple are returned. :param parent_class: All subclasses of this class defined within the module (not imported from elsewhere) will be extracted. :returns: The extracted class. :raises RuntimeError: If too many or too few classes are available in the module, unless the class name is specified. """ possible_resource_classes = import_classes_from_module(file_path, parent_class=parent_class) if class_name is None: if len(possible_resource_classes) == 1: _, resource_class = possible_resource_classes.popitem() else: if len(possible_resource_classes) == 0: raise RuntimeError(f"No subclasses of {parent_class.__name__!r} found in {file_path}") raise RuntimeError(f"Multiple subclasses of {parent_class.__name__!r} found in {file_path}:" f" {set(possible_resource_classes)}") else: resource_class = possible_resource_classes[class_name] return resource_class
[docs] def import_classes_from_module(file_path: Path, parent_class: type[T]) -> dict[str, type[T]]: """ Import all available resource classes from the module. :param file_path: The location of the module as a file path. :param parent_class: All subclasses of this class defined within the module (not imported from elsewhere) will be extracted. :returns: A mapping of class name to class. """ import_path = file_path_to_import_path(file_path) module = import_py_file(import_path, file_path) possible_resource_classes = {} for _, cls in inspect.getmembers(module, predicate=inspect.isclass): try: class_is_subclass_of_parent = issubclass(cls, parent_class) except TypeError: class_is_subclass_of_parent = False class_is_defined_in_this_module = (cls.__module__ == import_path) class_is_not_private = (not cls.__name__.startswith("_")) if class_is_subclass_of_parent and class_is_defined_in_this_module and class_is_not_private: possible_resource_classes[cls.__name__] = cls return possible_resource_classes
[docs] def file_path_to_import_path(file_path: Path) -> str: """ Convert a file path to an import path. :param file_path: The path to a Python file. :raises ValueError: If the path doesn't end in a '.py' extension. :Example: >>> file_path_to_import_path(Path("module.py")) 'module' >>> file_path_to_import_path(Path("path/to/module.py")) 'path.to.module' """ *path_components, file_name = file_path.parts module_name, extension = file_name.split(".") if extension != "py": raise ValueError(f"{file_path!r} does not appear to be a valid Python module") path_components.append(module_name) import_path = ".".join(path_components) return import_path
[docs] def import_py_file(import_path: str, file_path: Path) -> ModuleType: """ Import a .py file as a module. This is done using a :ref:`standard Python recipe <python3:importlib-examples>` via :mod:`importlib.util`. :param import_path: The import path added to :data:`sys.modules`. :param file_path: The path to the .py module. :returns: The imported module. :raises FileNotFoundError: If the path does not exist. """ try: spec = importlib.util.spec_from_file_location(import_path, file_path) if spec is None: raise RuntimeError("Imported module spec is unexpectedly 'None'") if spec.loader is None: raise RuntimeError("Imported module spec loader is unexpectedly 'None'") module = importlib.util.module_from_spec(spec) with edit_sys_path(prepend=[file_path.parent]): sys.modules[import_path] = module spec.loader.exec_module(module) except ModuleNotFoundError as error: if not file_path.exists(): raise FileNotFoundError(file_path) from error raise return module
[docs] @contextmanager def edit_sys_path(prepend: Sequence[Path] = (), append: Sequence[Path] = ()) -> Generator[None]: """ Temporarily add to :data:`sys.path` within a context manager. :param prepend: A sequence of paths to add to :data:`sys.path` *before* the current entries. :param append: A sequence of paths to add to :data:`sys.path` *after* the current entries. :seealso: This is used to enable local imports for the :func:`import_py_file`. """ original_sys_path = sys.path.copy() # otherwise we just reference the original try: sys.path = [str(path) for path in prepend] + sys.path + [str(path) for path in append] yield finally: sys.path = original_sys_path