"""Component-package descriptors and installed entry-point discovery."""
from __future__ import annotations
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from importlib import import_module
from importlib.metadata import entry_points
from pathlib import Path, PurePosixPath
from .catalog import ComponentSchema
from .component import Component
from .semver import parse_range, parse_version, satisfies
from .ui.design import Design
ENTRY_POINT_GROUP = "spaday.component_packages"
_PACKAGE_NAME = re.compile(r"[a-z0-9][a-z0-9._-]*\Z")
_NPM_NAME = re.compile(r"(?:@[a-z0-9][a-z0-9._~-]*/)?[a-z0-9][a-z0-9._~-]*\Z")
[docs]
@dataclass(frozen=True)
class ComponentPackage:
"""Assets and catalog metadata for one external component library.
``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. ``components`` contains the package's public
:class:`~spaday.component.Component` subclasses. Generated CEM classes
already carry schemas; hand-authored classes set ``Component.schema``.
``catalog`` returns those schemas without constructing components.
``imports`` publishes vendored modules under their bare specifiers as
``(specifier, relative_path)`` pairs, which :func:`~spaday.bootstrap.bootstrap`
emits as an import map. It exists for engines that register global custom
element names -- Perspective, a design system -- where a second copy on the
page throws from ``customElements.define`` and the two cannot coexist. A
package that publishes its copy this way lets every other library on the page
resolve the same bare specifier to it, so there is one copy rather than a
collision. A specifier ending in ``/`` maps a whole subtree, per the import-map
spec. Two packages publishing the same specifier at different paths is an
error: it is the ambiguity the feature exists to remove.
``provides`` records the JS libraries the package puts on the page, by npm
name, with the exact version it serves (``{"@awesome.me/webawesome": "3.1.0"}``);
a package's build writes them, so the Python side knows what the browser gets.
``design`` publishes the package's :class:`~spaday.ui.design.Design` — how it renders the
generic controls of :mod:`spaday.ui` — which a page selecting the package renders with.
``requires`` records libraries the package's own bundle imports without
shipping, with the npm version range it was built against
(``{"@awesome.me/webawesome": "^3.1.0"}``). :func:`resolve_component_packages`
reconciles them across the packages selected for a page: a page holds one copy
of a library, so two packages serving it at different versions, or a
requirement no selected package satisfies, is an error naming the packages and
versions, where the second copy would otherwise half-work. Packages serving the
same version of a library may both publish it; the page imports the first.
"""
name: str
assets_dir: Path
assets: Sequence[tuple[str, str]]
components: Sequence[type[Component]] = ()
imports: Sequence[tuple[str, str]] = ()
provides: Mapping[str, str] | Sequence[tuple[str, str]] = ()
requires: Mapping[str, str] | Sequence[tuple[str, str]] = ()
design: Design | None = None
def __post_init__(self) -> None:
if self.design is not None and not isinstance(self.design, Design):
raise TypeError(f"component package design must be a spaday.ui.Design, got {type(self.design).__name__}")
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))
imports = []
for specifier, path in self.imports:
if not specifier or specifier.strip() != specifier or any(c.isspace() for c in specifier):
raise ValueError(f"component package import specifier {specifier!r} must be a bare specifier with no whitespace")
import_path = PurePosixPath(path)
if import_path.is_absolute() or not path or ".." in import_path.parts:
raise ValueError("component package import paths must be relative and cannot contain '..'")
# a specifier mapping a subtree must map to one, per the import-map spec
if specifier.endswith("/") != path.endswith("/"):
raise ValueError(f"component package import {specifier!r} and its path must either both end in '/' or neither")
imports.append((specifier, import_path.as_posix() + ("/" if path.endswith("/") else "")))
object.__setattr__(self, "imports", tuple(imports))
object.__setattr__(self, "provides", _libraries(self.provides, "provides", parse_version))
object.__setattr__(self, "requires", _libraries(self.requires, "requires", parse_range))
components = tuple(self.components)
seen: set[str] = set()
for component in components:
if not isinstance(component, type) or not issubclass(component, Component):
raise TypeError("component package components must be Component subclasses")
if component.schema is None:
raise ValueError(f"component {component.__name__!r} does not define catalog schema")
if component.schema.tag != component.tag:
raise ValueError(f"component {component.__name__!r} schema tag does not match {component.tag!r}")
if component.tag in seen:
raise ValueError(f"component package contains duplicate tag {component.tag!r}")
seen.add(component.tag)
object.__setattr__(self, "components", components)
@property
def catalog(self) -> tuple[ComponentSchema, ...]:
"""Schemas for the package's public component classes."""
return tuple(
component.schema.model_copy(update={"class_name": component.__name__}) for component in self.components if component.schema is not None
)
def _libraries(value: Mapping[str, str] | Sequence[tuple[str, str]], field: str, parse) -> tuple[tuple[str, str], ...]:
"""Normalize ``provides`` / ``requires`` to sorted ``(npm name, version or range)`` pairs, rejecting
a name npm would not accept, a version or range that does not parse, and a library named twice."""
pairs = tuple(value.items() if isinstance(value, Mapping) else value)
for name, spec in pairs:
if not isinstance(name, str) or not _NPM_NAME.fullmatch(name):
raise ValueError(f"component package {field} names {name!r}, which is not an npm package name")
if not isinstance(spec, str):
raise ValueError(f"component package {field} gives {name!r} a {type(spec).__name__}, not a version string")
try:
parse(spec)
except ValueError as error:
raise ValueError(f"component package {field} gives {name!r} {spec!r}: {error}") from None
names = [name for name, _ in pairs]
duplicate = next((name for name in names if names.count(name) > 1), None)
if duplicate is not None:
raise ValueError(f"component package {field} names {duplicate!r} more than once")
return tuple(sorted(pairs))
def npm_package(specifier: str) -> str:
"""The npm package a bare import specifier belongs to: ``@scope/name/dist/x.js`` → ``@scope/name``."""
parts = specifier.split("/")
return "/".join(parts[:2] if specifier.startswith("@") else parts[:1])
def _reconcile(packages: Sequence[ComponentPackage]) -> None:
"""Reject a page whose packages disagree about a JS library: served at two versions, or required at
a version no selected package serves."""
served: dict[str, tuple[str, str]] = {}
for package in packages:
for library, version in package.provides:
if library in served and served[library][0] != version:
other, owner = served[library]
raise ValueError(
f"component packages {owner!r} and {package.name!r} serve different versions of {library} "
f"({other} and {version}); a page can hold only one copy, so select one of them or align their versions"
)
served.setdefault(library, (version, package.name))
for package in packages:
for library, range_ in package.requires:
if library not in served:
raise ValueError(
f"component package {package.name!r} requires {library} {range_}, which no selected package serves; "
"select the package that provides it"
)
version, owner = served[library]
if not satisfies(version, range_):
raise ValueError(f"component package {package.name!r} requires {library} {range_}, but {owner!r} serves {version}")
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'")
try:
module = import_module(module_name)
except ImportError as error:
raise ValueError(f"could not import component package Python path {spec!r}: {error}") from error
try:
value = getattr(module, attribute)
except AttributeError as error:
raise ValueError(f"component package Python path {spec!r} does not exist") from error
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:
candidates = _installed_entry_points()
matches = [candidate for candidate in candidates if candidate.name == name]
if not matches:
available = sorted({candidate.name for candidate in candidates})
choices = f"; available packages: {', '.join(available)}" if available else "; no component packages are installed"
raise ValueError(f"unknown component package {name!r}{choices}")
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")
_reconcile(resolved)
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)
)
[docs]
def discover_component_package_names() -> tuple[str, ...]:
"""Return installed component-package entry-point names without loading them."""
return tuple(sorted({candidate.name for candidate in _installed_entry_points()}))