Add component variant fallback priorities and bump to 1.1.0

This commit is contained in:
snw 2026-10-29 20:05:32 +03:00
parent 13398fa961
commit e3def2125a
8 changed files with 121 additions and 30 deletions

View file

@ -160,7 +160,17 @@ build(
) )
``` ```
`component_variant` — положительное целое число, нумерация **с 1**: значение `1` выбирает элемент с индексом `0`. Для веб-превью этот параметр можно передать в `run(component_variant=1)` или `create_app(component_variant=1)`, для сборки — в генератор. `DefaultGenerator` всегда создаёт веб-обёртку, поэтому выбранные для него строки должны подходить для вставки в HTML. `component_variant` — положительное целое число или непустой список/кортеж таких чисел в порядке приоритета. Нумерация **с 1**: значение `1` выбирает элемент с индексом `0`. Для веб-превью этот параметр можно передать в `run(component_variant=1)` или `create_app(component_variant=1)`, для сборки — в генератор. `DefaultGenerator` всегда создаёт веб-обёртку, поэтому выбранные для него строки должны подходить для вставки в HTML.
Начиная с 1.1, `component_variant=[3, 2]` выбирает третий вариант, а если его нет — второй. Выбор выполняется **для каждого компонента отдельно**: например, специальный компонент может содержать MCP-пример для LLM в варианте 3, а остальные компоненты используют общее описание REST API для людей из варианта 2. Можно задать более длинную цепочку, например `[3, 2, 1]`. Пустая строка считается явно заданным вариантом и не вызывает fallback.
```python
build(
title="LLM instructions",
generator=FilesGenerator(component_variant=[3, 2], content_extension="md"),
)
# Та же цепочка доступна в DefaultGenerator, run и create_app.
```
`FilesGenerator` требует `component_variant` и непустой `content_extension` (например, `md`, `.md`, `tex` или `txt`). Он возвращает `FilesBuildResult.files`: словарь `{имя_файла: содержимое}`. При `as_single_file=True` (по умолчанию) создаётся `{title}.{content_extension}`, страницы сортируются так же, как в веб-версии, и разделяются пустой строкой: `FilesGenerator` требует `component_variant` и непустой `content_extension` (например, `md`, `.md`, `tex` или `txt`). Он возвращает `FilesBuildResult.files`: словарь `{имя_файла: содержимое}`. При `as_single_file=True` (по умолчанию) создаётся `{title}.{content_extension}`, страницы сортируются так же, как в веб-версии, и разделяются пустой строкой:
@ -175,7 +185,7 @@ build(
При `as_single_file=False` создаются файлы `{number}. {page_name}.{content_extension}`, без заголовков-разделителей. Недопустимые символы в именах заменяются на `_`; пути не могут выйти из `build_directory`. Файлы записываются в UTF-8. Генератор не удаляет старые файлы из каталога — для разных форматов используйте разные каталоги. При `as_single_file=False` создаются файлы `{number}. {page_name}.{content_extension}`, без заголовков-разделителей. Недопустимые символы в именах заменяются на `_`; пути не могут выйти из `build_directory`. Файлы записываются в UTF-8. Генератор не удаляет старые файлы из каталога — для разных форматов используйте разные каталоги.
Содержимое вариантов вставляется без экранирования и конвертации; между блоками страницы добавляется `\n`. Веб-шаблоны, CSS, JS и assets при файловом экспорте не загружаются и не копируются: ссылки на изображения и другие ресурсы определяет сам компонент. У всех компонентов на странице должен быть выбранный вариант; иначе сборка завершается ошибкой с именем страницы и компонента, без подстановки HTML в другой формат. Содержимое вариантов вставляется без экранирования и конвертации; между блоками страницы добавляется `\n`. Веб-шаблоны, CSS, JS и assets при файловом экспорте не загружаются и не копируются: ссылки на изображения и другие ресурсы определяет сам компонент. У каждого компонента должен быть хотя бы один вариант из заданной цепочки; иначе сборка завершается ошибкой с именем страницы и компонента. Варианты вне цепочки автоматически не подставляются.
Встроенные компоненты по-прежнему предоставляют один HTML-вариант. Для других форматов определите собственные компоненты, как в примере выше. Вызов компонента теперь возвращает кортеж вариантов, а не строку: если вручную собираете составной компонент, используйте нужные элементы явно, например `H1(text)[0]`. Результат `@Component` нельзя превращать в обычный `list` перед возвратом со страницы: обычные списки/кортежи трактуются как последовательность блоков, а результат декоратора — как альтернативы одного блока. Встроенные компоненты по-прежнему предоставляют один HTML-вариант. Для других форматов определите собственные компоненты, как в примере выше. Вызов компонента теперь возвращает кортеж вариантов, а не строку: если вручную собираете составной компонент, используйте нужные элементы явно, например `H1(text)[0]`. Результат `@Component` нельзя превращать в обычный `list` перед возвратом со страницы: обычные списки/кортежи трактуются как последовательность блоков, а результат декоратора — как альтернативы одного блока.

View file

@ -1,3 +1,3 @@
"""Single source of truth for the SharedDocsLib package version.""" """Single source of truth for the SharedDocsLib package version."""
__version__ = "1.0.0" __version__ = "1.1.0"

View file

@ -13,7 +13,7 @@ from pathlib import Path
from typing import Iterable, Mapping, Sequence from typing import Iterable, Mapping, Sequence
from ._version import __version__ from ._version import __version__
from .hooks import ComponentOutput from .hooks import ComponentOutput, VariantSelection, normalise_variants
from .registry import PageDefinition, registry from .registry import PageDefinition, registry
from .ui import HeaderButton from .ui import HeaderButton
@ -39,7 +39,7 @@ def page_sort_key(page: PageDefinition) -> tuple[tuple[tuple[int, object], ...],
return tuple(_natural_part(part) for part in page.number.split(".")) return tuple(_natural_part(part) for part in page.number.split("."))
def _flatten_content(value: object, component_variant: int = 1) -> Iterable[str]: def _flatten_content(value: object, component_variant: VariantSelection = 1) -> Iterable[str]:
if value is None: if value is None:
return return
if isinstance(value, ComponentOutput): if isinstance(value, ComponentOutput):
@ -97,7 +97,7 @@ class Compiler:
manifest_url: str = "./manifest.json", manifest_url: str = "./manifest.json",
live_preview: bool = False, live_preview: bool = False,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
component_variant: int = 1, component_variant: VariantSelection = 1,
) -> None: ) -> None:
self.title = str(title) self.title = str(title)
self.assets_dir = Path(assets_dir).expanduser().resolve() self.assets_dir = Path(assets_dir).expanduser().resolve()
@ -111,9 +111,7 @@ class Compiler:
self.manifest_url = manifest_url self.manifest_url = manifest_url
self.live_preview = bool(live_preview) self.live_preview = bool(live_preview)
self.header_buttons = list(header_buttons or []) self.header_buttons = list(header_buttons or [])
if type(component_variant) is not int or component_variant < 1: self.component_variant = normalise_variants(component_variant)
raise ValueError("component_variant must be a positive integer (1-based)")
self.component_variant = component_variant
def build(self) -> BuildResult: def build(self) -> BuildResult:
pages = self.render_pages() pages = self.render_pages()

View file

@ -6,11 +6,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .compiler import BuildResult, Compiler from .compiler import BuildResult, Compiler
from .hooks import VariantSelection, normalise_variants
def _validate_variant(value: int) -> None:
if type(value) is not int or value < 1:
raise ValueError("component_variant must be a positive integer (1-based)")
def _filename(value: str) -> str: def _filename(value: str) -> str:
@ -37,10 +33,10 @@ class FilesBuildResult:
class DefaultGenerator: class DefaultGenerator:
"""Generate the standard four-artifact HTML bundle.""" """Generate the standard four-artifact HTML bundle."""
component_variant: int = 1 component_variant: VariantSelection = 1
def __post_init__(self) -> None: def __post_init__(self) -> None:
_validate_variant(self.component_variant) object.__setattr__(self, "component_variant", normalise_variants(self.component_variant))
def generate(self, compiler: Compiler, output: Path) -> BuildResult: def generate(self, compiler: Compiler, output: Path) -> BuildResult:
result = compiler.build() result = compiler.build()
@ -59,12 +55,12 @@ class DefaultGenerator:
class FilesGenerator: class FilesGenerator:
"""Export a selected component variant as raw content, without an HTML shell.""" """Export a selected component variant as raw content, without an HTML shell."""
component_variant: int component_variant: VariantSelection
content_extension: str content_extension: str
as_single_file: bool = True as_single_file: bool = True
def __post_init__(self) -> None: def __post_init__(self) -> None:
_validate_variant(self.component_variant) object.__setattr__(self, "component_variant", normalise_variants(self.component_variant))
if not isinstance(self.content_extension, str): if not isinstance(self.content_extension, str):
raise TypeError("content_extension must be a string") raise TypeError("content_extension must be a string")
extension = self.content_extension.removeprefix(".") extension = self.content_extension.removeprefix(".")

View file

@ -8,6 +8,20 @@ from typing import Callable, Generic, ParamSpec
from .registry import PageDefinition, registry from .registry import PageDefinition, registry
P = ParamSpec("P") 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, ...]): class ComponentOutput(tuple[str, ...]):
@ -20,15 +34,15 @@ class ComponentOutput(tuple[str, ...]):
output.component_name = name output.component_name = name
return output return output
def select(self, variant: int) -> str: def select(self, variant: VariantSelection) -> str:
if type(variant) is not int or variant < 1: variants = normalise_variants(variant)
raise ValueError("component_variant must be a positive integer (1-based)") for candidate in variants:
if variant > len(self): if candidate <= len(self):
raise ValueError( return self[candidate - 1]
f"Component {self.component_name} has {len(self)} variant(s); " requested = f"variant {variants[0]}" if len(variants) == 1 else f"variants {list(variants)}"
f"requested variant {variant}" raise ValueError(
) f"Component {self.component_name} has {len(self)} variant(s); requested {requested}"
return self[variant - 1] )
def _normalise_page_number(number: str | int | float) -> str: def _normalise_page_number(number: str | int | float) -> str:

View file

@ -14,6 +14,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, Response
from .compiler import BuildResult, Compiler from .compiler import BuildResult, Compiler
from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator
from .hooks import VariantSelection
from .registry import registry from .registry import registry
from .ui import HeaderButton from .ui import HeaderButton
@ -131,7 +132,7 @@ def create_app(
manifest_parh: str | None = None, manifest_parh: str | None = None,
live_preview: bool = False, live_preview: bool = False,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
component_variant: int = 1, component_variant: VariantSelection = 1,
cors_origins: Sequence[str] = ("*",), cors_origins: Sequence[str] = ("*",),
) -> FastAPI: ) -> FastAPI:
"""Compile registered pages and return a ready-to-serve FastAPI application.""" """Compile registered pages and return a ready-to-serve FastAPI application."""
@ -248,7 +249,7 @@ def run(
manifest_parh: str | None = None, manifest_parh: str | None = None,
live_preview: bool = False, live_preview: bool = False,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
component_variant: int = 1, component_variant: VariantSelection = 1,
cors_origins: Sequence[str] = ("*",), cors_origins: Sequence[str] = ("*",),
log_level: str = "info", log_level: str = "info",
) -> None: ) -> None:

View file

@ -155,3 +155,75 @@ def test_run_passes_variant_to_preview(monkeypatch):
server.run(component_variant=2) server.run(component_variant=2)
client = TestClient(served[0]) client = TestClient(served[0])
assert client.get("/manifest.json").json()["pages"][1]["content"] == "# Last" assert client.get("/manifest.json").json()["pages"][1]["content"] == "# Last"
def register_audience_page():
@Component
def specialised():
return ("<p>HTML</p>", "REST API example", "MCP example")
@Component
def shared():
return ("<p>HTML</p>", "Shared human instructions")
@Page("1", "Instructions")
def instructions():
return [specialised(), [shared(), "raw text"]]
@pytest.mark.parametrize("single", [True, False])
def test_fallback_is_selected_independently_for_each_component(tmp_path, single):
register_audience_page()
result = build(build_directory=tmp_path, generator=FilesGenerator([3, 2], "md", single))
content = next(iter(result.files.values()))
assert content.endswith("MCP example\nShared human instructions\nraw text")
assert "REST API example" not in content
def test_fallback_in_web_build_preview_and_run(tmp_path, monkeypatch):
import docslib.server as server
register_audience_page()
expected = "MCP example\nShared human instructions\nraw text"
result = build(build_directory=tmp_path, generator=DefaultGenerator([3, 2]))
assert result.manifest["pages"][0]["content"] == expected
client = TestClient(create_app(component_variant=[3, 2], live_preview=True))
assert client.get("/manifest.json").json()["pages"][0]["content"] == expected
served = []
monkeypatch.setattr(server.uvicorn, "run", lambda app, **kwargs: served.append(app))
server.run(component_variant=[3, 2])
assert served[0].state.docslib.result.manifest["pages"][0]["content"] == expected
def test_priority_order_empty_string_and_long_fallback_chain():
@Component
def alternatives():
return ("html", "human", "")
assert alternatives().select([2, 3]) == "human"
assert alternatives().select([3, 2]) == "" # Empty output is an explicit variant.
assert alternatives().select([10, 9, 2, 1]) == "human"
def test_no_available_fallback_fails_before_writing(tmp_path):
register_audience_page()
with pytest.raises(RuntimeError, match=r"Instructions.*shared.*requested variants \[3, 4\]"):
build(build_directory=tmp_path, generator=FilesGenerator([3, 4], "md"))
assert not list(tmp_path.iterdir())
@pytest.mark.parametrize("variants", [[], [0], [3, -1], [3, True], [3, "2"], [3, 1.5], [3, []]])
def test_invalid_fallback_lists_are_rejected_eagerly(variants):
for factory in (DefaultGenerator, lambda v: FilesGenerator(v, "md"),
lambda v: create_app(component_variant=v)):
with pytest.raises(ValueError, match="positive integer"):
factory(variants)
def test_fallback_configuration_is_copied():
variants = [3, 2]
generator = FilesGenerator(variants, "md")
app = create_app(component_variant=variants)
variants.clear()
assert generator.component_variant == (3, 2)
assert app.state.docslib.compiler.component_variant == (3, 2)

View file

@ -287,7 +287,7 @@ def test_all_endpoints_and_self_contained_build(tmp_path):
def test_build_writes_static_bundle(tmp_path): def test_build_writes_static_bundle(tmp_path):
assert __version__ == "1.0.0" assert __version__ == "1.1.0"
@Page("1", "Static page") @Page("1", "Static page")
def static_page(): def static_page():