Support Reactive helpers in live preview

This commit is contained in:
snw 2026-11-07 18:53:18 +03:00
parent e3def2125a
commit b36009bfc0
6 changed files with 141 additions and 24 deletions

View file

@ -349,7 +349,17 @@ PrintFullDocumentation();
## Live-preview ## Live-preview
В live-режиме браузер опрашивает только `/live`. Сервер следит за файлами, в которых объявлены страницы, а также за системными и пользовательскими CSS/JS. При изменении он пересобирает manifest; клиент сохраняет его и перезагружается с новым runtime. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер. В live-режиме браузер опрашивает только `/live`. Сервер следит за файлами страниц, импортированными модулями с функциями `@Reactive`, а также за системными и пользовательскими CSS/JS. При изменении он пересобирает manifest; клиент сохраняет его и перезагружается с новым runtime. В запускаемом файле вызов `run()` обязательно помещать под `if __name__ == "__main__"`, чтобы watcher не запускал второй сервер.
Используйте `@Reactive` для обычных функций уровня модуля, например для локализации или общих значений. Декоратор сохраняет аргументы и возвращаемое значение функции. При редактировании её файла live preview заново выполняет рендер всех страниц.
```python
from docslib import Reactive
@Reactive
def product_name() -> str:
return "Мой продукт"
```
HTTP-загрузка и исполнение произвольных `.py` файлов намеренно отсутствуют: такая ручка была бы удалённым выполнением кода. Live-preview работает с локальными исходниками проекта. HTTP-загрузка и исполнение произвольных `.py` файлов намеренно отсутствуют: такая ручка была бы удалённым выполнением кода. Live-preview работает с локальными исходниками проекта.

View file

@ -2,11 +2,11 @@
from ._version import __version__ from ._version import __version__
from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator from .generators import DefaultGenerator, FilesBuildResult, FilesGenerator
from .hooks import Component, Page from .hooks import Component, Page, Reactive
from .server import build, create_app, run from .server import build, create_app, run
from .ui import HeaderButton from .ui import HeaderButton
__all__ = [ __all__ = [
"Component", "DefaultGenerator", "FilesBuildResult", "FilesGenerator", "Component", "DefaultGenerator", "FilesBuildResult", "FilesGenerator",
"HeaderButton", "Page", "build", "create_app", "run", "HeaderButton", "Page", "Reactive", "build", "create_app", "run",
] ]

View file

@ -3,11 +3,12 @@ from __future__ import annotations
import inspect import inspect
from functools import update_wrapper from functools import update_wrapper
from pathlib import Path from pathlib import Path
from typing import Callable, Generic, ParamSpec from typing import Callable, Generic, ParamSpec, TypeVar, cast
from .registry import PageDefinition, registry from .registry import PageDefinition, registry
P = ParamSpec("P") P = ParamSpec("P")
R = TypeVar("R")
VariantSelection = int | list[int] | tuple[int, ...] VariantSelection = int | list[int] | tuple[int, ...]
@ -53,6 +54,30 @@ def _normalise_page_number(number: str | int | float) -> str:
return ".".join(parts) return ".".join(parts)
class Reactive(Generic[P, R]):
"""Watch a helper's source file in live preview and preserve its return value."""
def __init__(self, function: Callable[P, R]) -> None:
if not callable(function):
raise TypeError("@Reactive must decorate a callable")
update_wrapper(self, function)
self.function = function
self._source_key: tuple[str, str] | None = None
if inspect.isfunction(function) and "<locals>" not in function.__qualname__:
source = inspect.getsourcefile(function)
if source:
self._source_key = str(Path(source).resolve()), function.__qualname__
registry.register_reactive(self._source_key, function)
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R:
function = self.function
if self._source_key is not None:
latest = registry.reactive_function(self._source_key)
if latest is not None:
function = cast(Callable[P, R], latest)
return function(*args, **kwargs)
class Component(Generic[P]): class Component(Generic[P]):
"""Normalise a component's string or list/tuple of strings into variants.""" """Normalise a component's string or list/tuple of strings into variants."""
@ -102,5 +127,5 @@ class Page:
return function return function
__all__ = ["Component", "Page"] __all__ = ["Component", "Page", "Reactive"]

View file

@ -5,6 +5,8 @@ from pathlib import Path
from threading import RLock from threading import RLock
from typing import Callable from typing import Callable
ReactiveFunctions = dict[tuple[str, str], Callable[..., object]]
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class PageDefinition: class PageDefinition:
@ -18,6 +20,7 @@ class PageDefinition:
class Registry: class Registry:
def __init__(self) -> None: def __init__(self) -> None:
self._pages: dict[str, PageDefinition] = {} self._pages: dict[str, PageDefinition] = {}
self._reactive: ReactiveFunctions = {}
self._lock = RLock() self._lock = RLock()
def register(self, page: PageDefinition) -> None: def register(self, page: PageDefinition) -> None:
@ -31,6 +34,22 @@ class Registry:
with self._lock: with self._lock:
return list(self._pages.values()) return list(self._pages.values())
def register_reactive(self, key: tuple[str, str], function: Callable[..., object]) -> None:
with self._lock:
self._reactive[key] = function
def reactive_function(self, key: tuple[str, str]) -> Callable[..., object] | None:
with self._lock:
return self._reactive.get(key)
def sources(self) -> set[tuple[str, str]]:
with self._lock:
return {
(page.source_file, page.module) for page in self._pages.values() if page.source_file
} | {
(path, function.__module__) for (path, _), function in self._reactive.items()
}
def resolve_page( def resolve_page(
self, self,
target: str | int | float | Callable[..., object], target: str | int | float | Callable[..., object],
@ -79,13 +98,13 @@ class Registry:
) )
return candidates[0] return candidates[0]
def snapshot(self) -> dict[str, PageDefinition]: def snapshot(self) -> tuple[dict[str, PageDefinition], ReactiveFunctions]:
with self._lock: with self._lock:
return dict(self._pages) return self._pages.copy(), self._reactive.copy()
def restore(self, snapshot: dict[str, PageDefinition]) -> None: def restore(self, snapshot: tuple[dict[str, PageDefinition], ReactiveFunctions]) -> None:
with self._lock: with self._lock:
self._pages = dict(snapshot) self._pages, self._reactive = snapshot
def remove_source(self, source_file: str | Path) -> None: def remove_source(self, source_file: str | Path) -> None:
target = str(Path(source_file).resolve()) target = str(Path(source_file).resolve())
@ -95,11 +114,15 @@ class Registry:
for key, page in self._pages.items() for key, page in self._pages.items()
if not page.source_file or str(Path(page.source_file).resolve()) != target if not page.source_file or str(Path(page.source_file).resolve()) != target
} }
self._reactive = {
key: function for key, function in self._reactive.items() if key[0] != target
}
def clear(self) -> None: def clear(self) -> None:
"""Clear all registered pages. Primarily useful in test suites.""" """Clear all registered pages. Primarily useful in test suites."""
with self._lock: with self._lock:
self._pages.clear() self._pages.clear()
self._reactive.clear()
registry = Registry() registry = Registry()

View file

@ -1,10 +1,10 @@
from __future__ import annotations from __future__ import annotations
import importlib
import runpy import runpy
import sys import sys
from pathlib import Path from pathlib import Path
from threading import RLock from threading import RLock
from types import ModuleType
from typing import Mapping, Sequence from typing import Mapping, Sequence
import uvicorn import uvicorn
@ -57,20 +57,18 @@ class DocumentationSite:
return self._last_error return self._last_error
def _discover_sources(self) -> None: def _discover_sources(self) -> None:
for page in registry.pages(): self._source_modules.clear()
if not page.source_file: for source_file, module in registry.sources():
continue path = Path(source_file)
path = Path(page.source_file).resolve() self._source_modules.setdefault(path, set()).add(module)
self._source_modules.setdefault(path, set()).add(page.module) watched = self._source_modules.keys() | set(self.compiler.watched_files())
for path in self._source_mtimes.keys() - watched:
del self._source_mtimes[path]
for path in watched - self._source_mtimes.keys():
try: try:
self._source_mtimes[path] = path.stat().st_mtime_ns self._source_mtimes[path] = path.stat().st_mtime_ns
except OSError: except OSError:
continue continue
for path in self.compiler.watched_files():
try:
self._source_mtimes.setdefault(path, path.stat().st_mtime_ns)
except OSError:
continue
def refresh_changed_sources(self) -> bool: def refresh_changed_sources(self) -> bool:
if not self.live_preview: if not self.live_preview:
@ -88,11 +86,15 @@ class DocumentationSite:
return False return False
snapshot = registry.snapshot() snapshot = registry.snapshot()
module_snapshots: dict[ModuleType, dict[str, object]] = {}
try: try:
for path, _ in changed: for path, _ in changed:
self._reload_source(path) self._reload_source(path, module_snapshots)
new_result = self.compiler.build() new_result = self.compiler.build()
except Exception as error: except Exception as error:
for module, namespace in module_snapshots.items():
module.__dict__.clear()
module.__dict__.update(namespace)
registry.restore(snapshot) registry.restore(snapshot)
self._last_error = f"{type(error).__name__}: {error}" self._last_error = f"{type(error).__name__}: {error}"
for path, mtime in changed: for path, mtime in changed:
@ -103,19 +105,22 @@ class DocumentationSite:
self._last_error = None self._last_error = None
for path, mtime in changed: for path, mtime in changed:
self._source_mtimes[path] = mtime self._source_mtimes[path] = mtime
self._source_modules.clear()
self._discover_sources() self._discover_sources()
return True return True
def _reload_source(self, path: Path) -> None: def _reload_source(self, path: Path, snapshots: dict[ModuleType, dict[str, object]]) -> None:
modules = self._source_modules.get(path, set()) modules = self._source_modules.get(path, set())
if not modules: if not modules:
return return
registry.remove_source(path) registry.remove_source(path)
reloadable = [name for name in modules if name not in {"__main__", "<run_path>"} and name in sys.modules] reloadable = [name for name in modules if name not in {"__main__", "<run_path>"} and name in sys.modules]
if reloadable: if reloadable:
# Read the source directly so a same-size edit within one second bypasses stale bytecode.
code = compile(path.read_bytes(), str(path), "exec", dont_inherit=True)
for name in reloadable: for name in reloadable:
importlib.reload(sys.modules[name]) module = sys.modules[name]
snapshots.setdefault(module, module.__dict__.copy())
exec(code, module.__dict__)
else: else:
# run_name is deliberately not __main__, so a guarded run() call is not executed again. # run_name is deliberately not __main__, so a guarded run() call is not executed again.
runpy.run_path(str(path), run_name=f"_docslib_live_{abs(hash(path))}") runpy.run_path(str(path), run_name=f"_docslib_live_{abs(hash(path))}")

View file

@ -1,8 +1,10 @@
import base64 import base64
import importlib.util
import io import io
import json import json
import os import os
import runpy import runpy
import sys
import zipfile import zipfile
from pathlib import Path from pathlib import Path
@ -343,6 +345,58 @@ def test_live_preview_reloads_a_changed_main_source(tmp_path):
assert "two" in client.get("/manifest.json").json()["pages"][0]["content"] assert "two" in client.get("/manifest.json").json()["pages"][0]["content"]
def test_live_preview_reloads_imported_reactive_helpers(tmp_path, monkeypatch):
source = tmp_path / "reactive_preview_helper.py"
initial_source = (
"from docslib import Reactive\n"
"TITLE = 'one'\n"
"@Reactive\n"
"def product_name(prefix=''): return prefix + TITLE\n"
)
source.write_text(initial_source, encoding="utf-8")
os.utime(source, ns=(1_800_000_000_000_000_000,) * 2)
spec = importlib.util.spec_from_file_location(source.stem, source)
helper = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, source.stem, helper)
spec.loader.exec_module(helper)
product_name = helper.product_name
@Page("1", "First")
def first():
return product_name("First ")
@Page("2", "Second")
def second():
return product_name(prefix="Second ")
client = TestClient(create_app(live_preview=True))
initial = client.get("/manifest.json").json()
assert [page["content"] for page in initial["pages"]] == ["First one", "Second one"]
# Keep both file size and whole-second mtime to exercise rapid saves.
updated_source = initial_source.replace("'one'", "'two'")
source.write_text(updated_source, encoding="utf-8")
os.utime(source, ns=(1_800_000_000_100_000_000,) * 2)
live = client.get("/live").json()
assert live["changed"] is True
assert live["revision"] != initial["revision"]
updated = client.get("/manifest.json").json()
assert [page["content"] for page in updated["pages"]] == ["First two", "Second two"]
source.write_text(updated_source.replace("'two'", "None"), encoding="utf-8")
live = client.get("/live").json()
assert live["changed"] is False
assert live["error"].startswith("RuntimeError:")
assert client.get("/manifest.json").json() == updated
assert product_name() == "two"
source.write_text(updated_source.replace("'two'", "'three'"), encoding="utf-8")
live = client.get("/live").json()
assert live["changed"] is True
assert live["error"] is None
assert product_name() == "three"
def test_live_preview_rebuilds_changed_custom_javascript(tmp_path): def test_live_preview_rebuilds_changed_custom_javascript(tmp_path):
custom_js = tmp_path / "inject.js" custom_js = tmp_path / "inject.js"
custom_js.write_text("window.customVersion = 1;", encoding="utf-8") custom_js.write_text("window.customVersion = 1;", encoding="utf-8")