SharedDocsLib/docslib/hooks.py

92 lines
3.2 KiB
Python

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")
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: int) -> str:
if type(variant) is not int or variant < 1:
raise ValueError("component_variant must be a positive integer (1-based)")
if variant > len(self):
raise ValueError(
f"Component {self.component_name} has {len(self)} variant(s); "
f"requested variant {variant}"
)
return self[variant - 1]
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"]