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}`, страницы сортируются так же, как в веб-версии, и разделяются пустой строкой:
|
`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` перед возвратом со страницы: обычные списки/кортежи трактуются как последовательность блоков, а результат декоратора — как альтернативы одного блока.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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(".")
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
return self[candidate - 1]
|
||||||
|
requested = f"variant {variants[0]}" if len(variants) == 1 else f"variants {list(variants)}"
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Component {self.component_name} has {len(self)} variant(s); "
|
f"Component {self.component_name} has {len(self)} variant(s); requested {requested}"
|
||||||
f"requested variant {variant}"
|
|
||||||
)
|
)
|
||||||
return self[variant - 1]
|
|
||||||
|
|
||||||
|
|
||||||
def _normalise_page_number(number: str | int | float) -> str:
|
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 .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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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():
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue