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}`, страницы сортируются так же, как в веб-версии, и разделяются пустой строкой:
@ -175,7 +185,7 @@ build(
При `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` перед возвратом со страницы: обычные списки/кортежи трактуются как последовательность блоков, а результат декоратора — как альтернативы одного блока.

View file

@ -1,3 +1,3 @@
"""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 ._version import __version__
from .hooks import ComponentOutput
from .hooks import ComponentOutput, VariantSelection, normalise_variants
from .registry import PageDefinition, registry
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("."))
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:
return
if isinstance(value, ComponentOutput):
@ -97,7 +97,7 @@ class Compiler:
manifest_url: str = "./manifest.json",
live_preview: bool = False,
header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None,
component_variant: int = 1,
component_variant: VariantSelection = 1,
) -> None:
self.title = str(title)
self.assets_dir = Path(assets_dir).expanduser().resolve()
@ -111,9 +111,7 @@ class Compiler:
self.manifest_url = manifest_url
self.live_preview = bool(live_preview)
self.header_buttons = list(header_buttons or [])
if type(component_variant) is not int or component_variant < 1:
raise ValueError("component_variant must be a positive integer (1-based)")
self.component_variant = component_variant
self.component_variant = normalise_variants(component_variant)
def build(self) -> BuildResult:
pages = self.render_pages()

View file

@ -6,11 +6,7 @@ from dataclasses import dataclass
from pathlib import Path
from .compiler import BuildResult, Compiler
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)")
from .hooks import VariantSelection, normalise_variants
def _filename(value: str) -> str:
@ -37,10 +33,10 @@ class FilesBuildResult:
class DefaultGenerator:
"""Generate the standard four-artifact HTML bundle."""
component_variant: int = 1
component_variant: VariantSelection = 1
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:
result = compiler.build()
@ -59,12 +55,12 @@ class DefaultGenerator:
class FilesGenerator:
"""Export a selected component variant as raw content, without an HTML shell."""
component_variant: int
component_variant: VariantSelection
content_extension: str
as_single_file: bool = True
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):
raise TypeError("content_extension must be a string")
extension = self.content_extension.removeprefix(".")

View file

@ -8,6 +8,20 @@ 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, ...]):
@ -20,15 +34,15 @@ class ComponentOutput(tuple[str, ...]):
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 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:

View file

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

View file

@ -155,3 +155,75 @@ def test_run_passes_variant_to_preview(monkeypatch):
server.run(component_variant=2)
client = TestClient(served[0])
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):
assert __version__ == "1.0.0"
assert __version__ == "1.1.0"
@Page("1", "Static page")
def static_page():