Add component variant fallback priorities and bump to 1.1.0
This commit is contained in:
parent
13398fa961
commit
e3def2125a
8 changed files with 121 additions and 30 deletions
14
README.md
14
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` перед возвратом со страницы: обычные списки/кортежи трактуются как последовательность блоков, а результат декоратора — как альтернативы одного блока.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
"""Single source of truth for the SharedDocsLib package version."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__version__ = "1.1.0"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(".")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue