from __future__ import annotations import inspect from functools import update_wrapper from pathlib import Path from typing import Callable, Generic, ParamSpec from .registry import PageDefinition, registry P = ParamSpec("P") VariantSelection = int | list[int] | tuple[int, ...] def normalise_variants(value: VariantSelection) -> tuple[int, ...]: """Snapshot a 1-based priority list, validating every fallback eagerly.""" variants = (value,) if type(value) is int else value if not isinstance(variants, (list, tuple)) or not variants or any( type(item) is not int or item < 1 for item in variants ): raise ValueError( "component_variant must be a positive integer or a non-empty list/tuple " "of positive integers (1-based)" ) return tuple(variants) class ComponentOutput(tuple[str, ...]): """A component's alternatives, distinct from a page's sequence of blocks.""" component_name: str def __new__(cls, variants: tuple[str, ...], name: str): output = super().__new__(cls, variants) output.component_name = name return output def select(self, variant: VariantSelection) -> str: variants = normalise_variants(variant) for candidate in variants: if candidate <= len(self): return self[candidate - 1] requested = f"variant {variants[0]}" if len(variants) == 1 else f"variants {list(variants)}" raise ValueError( f"Component {self.component_name} has {len(self)} variant(s); requested {requested}" ) def _normalise_page_number(number: str | int | float) -> str: value = str(number).strip().strip(".") parts = [part.strip() for part in value.split(".")] if not value or any(not part for part in parts): raise ValueError("Page number must contain non-empty dot-separated parts") return ".".join(parts) class Component(Generic[P]): """Normalise a component's string or list/tuple of strings into variants.""" def __init__(self, function: Callable[P, str | list[str] | tuple[str, ...]]) -> None: if not callable(function): raise TypeError("@Component must decorate a callable") self.function = function update_wrapper(self, function) def __call__(self, *args: P.args, **kwargs: P.kwargs) -> ComponentOutput: result = self.function(*args, **kwargs) if isinstance(result, str): result = (result,) if not isinstance(result, (list, tuple)) or not result or any( not isinstance(item, str) for item in result ): raise TypeError( f"Component {self.__name__} must return str or a non-empty list/tuple of strings" ) return ComponentOutput(tuple(result), self.__name__) class Page: """Register a zero-argument function as a numbered documentation page.""" def __init__(self, number: str | int | float, title: str) -> None: self.number = _normalise_page_number(number) self.title = str(title).strip() if not self.title: raise ValueError("Page title cannot be empty") def __call__(self, function: Callable[[], object]) -> Callable[[], object]: signature = inspect.signature(function) if signature.parameters: raise TypeError(f"Page function {function.__name__} must not accept arguments") source = inspect.getsourcefile(function) source_file = str(Path(source).resolve()) if source else None registry.register( PageDefinition( number=self.number, title=self.title, render=function, module=function.__module__, source_file=source_file, ) ) return function __all__ = ["Component", "Page"]