Source code for mini_arcade.utils.module_loader
"""
Module for discovering and loading packages from a specified directory and namespace.
This module defines the OneLevelPackageLoader class, which can be used to discover and
load packages from a specified directory and namespace. It also defines the
DiscoveredPackage dataclass, which represents a discovered package,
and the load_command_packages function, which is a convenience function for loading
command packages.
"""
from __future__ import annotations
import importlib
from dataclasses import dataclass
from pathlib import Path
[docs]
@dataclass(frozen=True)
class DiscoveredPackage:
"""
Represents a discovered package.
:ivar import_name: The import name of the package
(e.g. "mini_arcade.modules.game_runner")."
:ivar path: The filesystem path to the package.
"""
import_name: str
path: Path
[docs]
class ModuleDiscoveryError(RuntimeError):
"""Raised when there is an error discovering modules."""
[docs]
class ModuleImportError(RuntimeError):
"""Raised when there is an error importing modules."""
[docs]
class OneLevelPackageLoader:
"""
Loads packages from a specified directory and namespace.
:param base_namespace: The base namespace for the packages (e.g. "mini_arcade.modules").
:type base_namespace: str
:param base_dir: The base directory to search for packages.
:type base_dir: str | Path
:param require_init: Whether to require an __init__.py file in each package (default: True).
:type require_init: bool
:param strict: Whether to raise an error if a package fails to import (default: True).
:type strict: bool
:param import_commands_fallback: Whether to attempt importing <package>.commands as a
fallback (default: False).
:type import_commands_fallback: bool
"""
# pylint: disable=too-many-arguments
def __init__(
self,
*,
base_namespace: str,
base_dir: str | Path,
require_init: bool = True,
strict: bool = True,
import_commands_fallback: bool = False,
):
self.base_namespace = base_namespace.strip(".")
self.base_dir = Path(base_dir).resolve()
self.require_init = require_init
self.strict = strict
self.import_commands_fallback = import_commands_fallback
if not self.base_dir.exists() or not self.base_dir.is_dir():
raise ModuleDiscoveryError(
f"base_dir does not exist or is not a dir: {self.base_dir}"
)
# pylint: enable=too-many-arguments
[docs]
def discover(self) -> list[DiscoveredPackage]:
"""
Discover packages in the base directory.
:return: A list of DiscoveredPackage instances.
:rtype: list[DiscoveredPackage]
"""
out: list[DiscoveredPackage] = []
for child in sorted(
self.base_dir.iterdir(), key=lambda p: p.name.lower()
):
if not child.is_dir():
continue
if child.name.startswith(("_", ".")):
continue
if self.require_init and not (child / "__init__.py").exists():
continue
out.append(
DiscoveredPackage(
import_name=f"{self.base_namespace}.{child.name}",
path=child,
)
)
return out
[docs]
def load_all(self) -> list[DiscoveredPackage]:
"""
Load all discovered packages.
:return: A list of successfully loaded DiscoveredPackage instances.
:rtype: list[DiscoveredPackage]
:raises ModuleImportError: If strict is True and a package fails to import.
"""
loaded: list[DiscoveredPackage] = []
for pkg in self.discover():
try:
# Primary: import the package (executes __init__.py)
importlib.import_module(pkg.import_name)
# Optional fallback: import <pkg>.commands if you want
if self.import_commands_fallback:
try:
importlib.import_module(f"{pkg.import_name}.commands")
except ModuleNotFoundError:
pass
loaded.append(pkg)
except Exception as e: # pylint: disable=broad-exception-caught
if self.strict:
raise ModuleImportError(
f"Failed to import {pkg.import_name} from {pkg.path}"
) from e
return loaded
[docs]
def load_command_packages(
*,
base_namespace: str,
base_dir: str | Path,
strict: bool = True,
) -> list[DiscoveredPackage]:
"""
Load command packages from the specified directory and namespace.
:param base_namespace: The base namespace for the command packages (e.g. "mini_arcade.modules").
:type base_namespace: str
:param base_dir: The base directory to search for command packages.
:type base_dir: str | Path
:param strict: Whether to raise an error if a package fails to import (default: True).
:type strict: bool
:return: A list of successfully loaded DiscoveredPackage instances.
:rtype: list[DiscoveredPackage]
"""
return OneLevelPackageLoader(
base_namespace=base_namespace,
base_dir=base_dir,
strict=strict,
import_commands_fallback=False, # keep your rule
).load_all()