diff --git a/README.md b/README.md index d9129b9..9a9e257 100644 --- a/README.md +++ b/README.md @@ -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` перед возвратом со страницы: обычные списки/кортежи трактуются как последовательность блоков, а результат декоратора — как альтернативы одного блока. diff --git a/docslib/_version.py b/docslib/_version.py index d0d6ad0..098a6e9 100644 --- a/docslib/_version.py +++ b/docslib/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the SharedDocsLib package version.""" -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/docslib/compiler.py b/docslib/compiler.py index 850ad45..a006b77 100644 --- a/docslib/compiler.py +++ b/docslib/compiler.py @@ -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() diff --git a/docslib/generators.py b/docslib/generators.py index 41121d4..69eca17 100644 --- a/docslib/generators.py +++ b/docslib/generators.py @@ -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(".") diff --git a/docslib/hooks.py b/docslib/hooks.py index 9ab2144..d0f1c7c 100644 --- a/docslib/hooks.py +++ b/docslib/hooks.py @@ -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: diff --git a/docslib/server.py b/docslib/server.py index 856e52f..0a42e25 100644 --- a/docslib/server.py +++ b/docslib/server.py @@ -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: diff --git a/tests/test_generators.py b/tests/test_generators.py index eb879a3..4101084 100644 --- a/tests/test_generators.py +++ b/tests/test_generators.py @@ -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 ("
HTML
", "REST API example", "MCP example") + + @Component + def shared(): + return ("HTML
", "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) diff --git a/tests/test_server.py b/tests/test_server.py index 602acec..adef9d9 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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():