Source code for litestar_autowire.integrations

"""Integration objects for Litestar Autowire."""

import importlib
from collections.abc import Callable, Iterable
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Protocol, TypeAlias, TypeGuard, cast

from litestar_autowire.discovery import discover_queue_tasks, import_optional_module

if TYPE_CHECKING:
    from litestar import Controller
    from litestar.config.app import AppConfig
    from litestar.events import EventListener

    from litestar_autowire.config import AutowireConfig

AutowireLoaderCallable: TypeAlias = Callable[[str], object]


def _new_task_name_set() -> set[str]:
    return set()


[docs] @dataclass(slots=True) class AutowireContext: """Shared state passed to Autowire integrations.""" app_config: "AppConfig" config: "AutowireConfig" feature_packages: tuple[str, ...] controllers: list["type[Controller]"] listeners: list["EventListener"] router_class: type[Any] | None = None task_names: set[str] = field(default_factory=_new_task_name_set) loaded_task_count: int = 0 @property def task_count(self) -> int: """Return the total task count reported by integrations.""" return len(self.task_names) + self.loaded_task_count
[docs] def record_task_count(self, count: int) -> None: """Add a task count reported by a loader integration.""" if count < 0: msg = "Task count must be zero or greater." raise ValueError(msg) self.loaded_task_count += count
[docs] class AutowireIntegration(Protocol): """Protocol for extending Autowire's discovery lifecycle.""" @property def name(self) -> str: """Integration name used for validation and diagnostics.""" ...
[docs] def on_autowire(self, context: AutowireContext) -> None: """Apply integration behavior to the shared Autowire context.""" ...
AutowireIntegrationInput: TypeAlias = str | AutowireIntegration AutowireIntegrationsInput: TypeAlias = Iterable[AutowireIntegrationInput] | AutowireIntegrationInput
[docs] @dataclass(frozen=True, slots=True, init=False) class AutowireLoader: """Load discovered feature modules with a callable or dotted callable string.""" name: str modules: tuple[str, ...] loader: AutowireLoaderCallable | str
[docs] def __init__( self, *, name: str, modules: Iterable[str] | str, loader: AutowireLoaderCallable | str, ) -> None: """Initialize the loader integration. Args: name: Integration name used for validation and diagnostics. modules: Submodule names to load below each discovered feature package. loader: Callable or ``"pkg.module:func"`` string called with each existing module path. """ if not name: msg = "AutowireLoader requires a non-empty name." raise ValueError(msg) object.__setattr__(self, "name", name) object.__setattr__(self, "modules", _as_tuple(modules)) object.__setattr__(self, "loader", loader)
[docs] def on_autowire(self, context: AutowireContext) -> None: """Load configured modules for every discovered feature package.""" loader = resolve_dotted_callable(self.loader) if isinstance(self.loader, str) else self.loader for feature_package in context.feature_packages: for module_name in self.modules: module_path = f"{feature_package}.{module_name}" if import_optional_module(module_path) is None: continue result = loader(module_path) if isinstance(result, int) and not isinstance(result, bool): context.record_task_count(result)
@dataclass(frozen=True, slots=True) class DishkaIntegration: """Wrap discovered controllers with Dishka's Litestar router.""" name: str = field(default="dishka", init=False) def on_autowire(self, context: AutowireContext) -> None: """Resolve and apply Dishka's router class.""" if context.router_class is not None: return try: litestar_integration = importlib.import_module("dishka.integrations.litestar") except ModuleNotFoundError as exc: if exc.name == "dishka": msg = "Dishka router support requires the optional 'litestar-autowire[dishka]' dependency." raise RuntimeError(msg) from exc raise context.router_class = cast("type[Any]", litestar_integration.DishkaRouter) @dataclass(frozen=True, slots=True) class QueuesIntegration: """Discover Litestar Queues task modules.""" name: str = field(default="queues", init=False) def on_autowire(self, context: AutowireContext) -> None: """Import task modules through ``litestar_queues.discover_tasks``.""" task_names = discover_queue_tasks( context.config.domain_packages, context.config.task_modules, force_reload=context.config.force_reload_tasks, ) context.task_names.update(task_names) BUILTIN_INTEGRATIONS: dict[str, Callable[[], AutowireIntegration]] = { "dishka": DishkaIntegration, "queues": QueuesIntegration, } SUPPORTED_INTEGRATIONS = frozenset(BUILTIN_INTEGRATIONS) BUILTIN_INTEGRATION_TYPES = (DishkaIntegration, QueuesIntegration) def normalize_integrations(value: AutowireIntegrationsInput) -> tuple[AutowireIntegration, ...]: """Normalize integration aliases and objects into integration instances.""" integrations = tuple(_normalize_integration(integration) for integration in _as_integration_tuple(value)) _validate_integration_names(integrations) return integrations def resolve_dotted_callable(value: str) -> AutowireLoaderCallable: """Resolve a ``pkg.module:func`` string to a callable.""" module_name, separator, attribute_name = value.partition(":") if not separator: module_name, separator, attribute_name = value.rpartition(".") if not module_name or not attribute_name or not separator: msg = "Dotted callables must use 'pkg.module:func'." raise ValueError(msg) try: module = importlib.import_module(module_name) except ModuleNotFoundError as exc: if exc.name == module_name or module_name.startswith(f"{exc.name}."): msg = f"Could not import module for dotted callable: {module_name}" raise ValueError(msg) from exc raise try: value = getattr(module, attribute_name) except AttributeError as exc: msg = f"Dotted callable module {module_name!r} does not define {attribute_name!r}." raise ValueError(msg) from exc if not callable(value): msg = f"Dotted callable {module_name}:{attribute_name} is not callable." raise TypeError(msg) return cast("AutowireLoaderCallable", value) def _normalize_integration(integration: AutowireIntegrationInput) -> AutowireIntegration: if isinstance(integration, str): integration_type = BUILTIN_INTEGRATIONS.get(integration) if integration_type is None: msg = f"Unsupported Autowire integration(s): {integration}" raise ValueError(msg) return integration_type() if not _is_autowire_integration(integration): msg = "Autowire integration objects must define a non-empty string 'name' and callable 'on_autowire'." raise TypeError(msg) return integration def _as_integration_tuple(value: AutowireIntegrationsInput) -> tuple[AutowireIntegrationInput, ...]: if isinstance(value, str): return (value,) if _is_autowire_integration(value): return (value,) try: return tuple(cast("Iterable[AutowireIntegrationInput]", value)) except TypeError as exc: msg = "Autowire integrations must be strings, integration objects, or iterables of those values." raise TypeError(msg) from exc def _validate_integration_names(integrations: tuple[AutowireIntegration, ...]) -> None: seen: set[str] = set() for integration in integrations: if integration.name in seen: msg = f"Duplicate Autowire integration name: {integration.name}" raise ValueError(msg) seen.add(integration.name) if integration.name in SUPPORTED_INTEGRATIONS and not isinstance(integration, BUILTIN_INTEGRATION_TYPES): msg = f"Custom Autowire integration name conflict with built-in integration: {integration.name}" raise ValueError(msg) def _is_autowire_integration(value: object) -> TypeGuard[AutowireIntegration]: if isinstance(value, str): return False name = getattr(value, "name", None) hook = getattr(value, "on_autowire", None) return isinstance(name, str) and bool(name) and callable(hook) def _as_tuple(value: Iterable[str] | str) -> tuple[str, ...]: if isinstance(value, str): return (value,) return tuple(value)