Source code for spaday.packages
"""Component-package descriptors and installed entry-point discovery."""
from __future__ import annotations
import re
from collections.abc import Sequence
from dataclasses import dataclass
from importlib import import_module
from importlib.metadata import entry_points
from pathlib import Path, PurePosixPath
ENTRY_POINT_GROUP = "spaday.component_packages"
_PACKAGE_NAME = re.compile(r"[a-z0-9][a-z0-9._-]*\Z")
[docs]
@dataclass(frozen=True)
class ComponentPackage:
"""Assets that register one external component library in the browser.
``assets`` contains ``("css" | "js", relative_path)`` pairs under
``assets_dir``. Backends serve that directory at
``{prefix}/components/{name}``; :func:`spaday.bootstrap.bootstrap` emits
the matching tags.
"""
name: str
assets_dir: Path
assets: Sequence[tuple[str, str]]
def __post_init__(self) -> None:
if not _PACKAGE_NAME.fullmatch(self.name):
raise ValueError("component package name must contain only lowercase letters, digits, '.', '_', or '-'")
normalized = []
for kind, path in self.assets:
if kind not in ("css", "js"):
raise ValueError("component package asset kind must be 'css' or 'js'")
asset_path = PurePosixPath(path)
if asset_path.is_absolute() or not path or ".." in asset_path.parts:
raise ValueError("component package asset paths must be relative and cannot contain '..'")
normalized.append((kind, asset_path.as_posix()))
object.__setattr__(self, "assets_dir", Path(self.assets_dir))
object.__setattr__(self, "assets", tuple(normalized))
PackageRef = ComponentPackage | str
def package_url_prefix(package: ComponentPackage, base: str = "") -> str:
"""URL prefix where a backend serves ``package.assets_dir``."""
return f"{base}/components/{package.name}"
def _require_package(value: object, source: str) -> ComponentPackage:
if not isinstance(value, ComponentPackage):
raise TypeError(f"{source} must expose a ComponentPackage, got {type(value).__name__}")
return value
def _from_python_path(spec: str) -> ComponentPackage:
module_name, separator, attribute = spec.partition(":")
if not separator or not module_name or not attribute:
raise ValueError(f"invalid component package Python path {spec!r}; expected 'module:attribute'")
value = getattr(import_module(module_name), attribute)
return _require_package(value, f"component package Python path {spec!r}")
def _installed_entry_points():
return tuple(entry_points(group=ENTRY_POINT_GROUP))
def _from_entry_point(name: str) -> ComponentPackage:
matches = [candidate for candidate in _installed_entry_points() if candidate.name == name]
if not matches:
raise ValueError(f"unknown component package {name!r}; no {ENTRY_POINT_GROUP!r} entry point is installed")
if len(matches) > 1:
raise ValueError(f"multiple {ENTRY_POINT_GROUP!r} entry points are named {name!r}")
return _require_package(matches[0].load(), f"component package entry point {name!r}")
[docs]
def resolve_component_packages(packages: PackageRef | Sequence[PackageRef] = ()) -> tuple[ComponentPackage, ...]:
"""Resolve descriptors, ``module:attribute`` paths, or installed entry-point names.
Entry points are loaded only when explicitly named; installing an integration
never injects assets into unrelated applications.
"""
refs = (packages,) if isinstance(packages, (str, ComponentPackage)) else packages
resolved = tuple(
package if isinstance(package, ComponentPackage) else (_from_python_path(package) if ":" in package else _from_entry_point(package))
for package in refs
)
names = [package.name for package in resolved]
duplicate = next((name for name in names if names.count(name) > 1), None)
if duplicate is not None:
raise ValueError(f"component package {duplicate!r} was selected more than once")
return resolved
[docs]
def discover_component_packages() -> tuple[ComponentPackage, ...]:
"""Load every installed component-package entry point, sorted by entry-point name."""
return tuple(
_require_package(candidate.load(), f"component package entry point {candidate.name!r}")
for candidate in sorted(_installed_entry_points(), key=lambda candidate: candidate.name)
)