131 lines
4.9 KiB
Python
131 lines
4.9 KiB
Python
from __future__ import annotations
|
|
|
|
import inspect
|
|
from functools import update_wrapper
|
|
from pathlib import Path
|
|
from typing import Callable, Generic, ParamSpec, TypeVar, cast
|
|
|
|
from .registry import PageDefinition, registry
|
|
|
|
P = ParamSpec("P")
|
|
R = TypeVar("R")
|
|
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 Reactive(Generic[P, R]):
|
|
"""Watch a helper's source file in live preview and preserve its return value."""
|
|
|
|
def __init__(self, function: Callable[P, R]) -> None:
|
|
if not callable(function):
|
|
raise TypeError("@Reactive must decorate a callable")
|
|
update_wrapper(self, function)
|
|
self.function = function
|
|
self._source_key: tuple[str, str] | None = None
|
|
if inspect.isfunction(function) and "<locals>" not in function.__qualname__:
|
|
source = inspect.getsourcefile(function)
|
|
if source:
|
|
self._source_key = str(Path(source).resolve()), function.__qualname__
|
|
registry.register_reactive(self._source_key, function)
|
|
|
|
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
|
|
function = self.function
|
|
if self._source_key is not None:
|
|
latest = registry.reactive_function(self._source_key)
|
|
if latest is not None:
|
|
function = cast(Callable[P, R], latest)
|
|
return function(*args, **kwargs)
|
|
|
|
|
|
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", "Reactive"]
|
|
|