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") 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]): """Turn a function returning HTML into a reusable documentation component.""" def __init__(self, function: Callable[P, 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) -> str: result = self.function(*args, **kwargs) if not isinstance(result, str): raise TypeError(f"Component {self.__name__} must return str, got {type(result).__name__}") return result 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"]