Support Reactive helpers in live preview
This commit is contained in:
parent
e3def2125a
commit
b36009bfc0
6 changed files with 141 additions and 24 deletions
12
README.md
12
README.md
|
|
@ -349,7 +349,17 @@ PrintFullDocumentation();
|
|||
|
||||
## 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 работает с локальными исходниками проекта.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
from ._version import __version__
|
||||
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 .ui import HeaderButton
|
||||
|
||||
__all__ = [
|
||||
"Component", "DefaultGenerator", "FilesBuildResult", "FilesGenerator",
|
||||
"HeaderButton", "Page", "build", "create_app", "run",
|
||||
"HeaderButton", "Page", "Reactive", "build", "create_app", "run",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,11 +3,12 @@ from __future__ import annotations
|
|||
import inspect
|
||||
from functools import update_wrapper
|
||||
from pathlib import Path
|
||||
from typing import Callable, Generic, ParamSpec
|
||||
from typing import Callable, Generic, ParamSpec, TypeVar, cast
|
||||
|
||||
from .registry import PageDefinition, registry
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
VariantSelection = int | list[int] | tuple[int, ...]
|
||||
|
||||
|
||||
|
|
@ -53,6 +54,30 @@ def _normalise_page_number(number: str | int | float) -> str:
|
|||
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]):
|
||||
"""Normalise a component's string or list/tuple of strings into variants."""
|
||||
|
||||
|
|
@ -102,5 +127,5 @@ class Page:
|
|||
return function
|
||||
|
||||
|
||||
__all__ = ["Component", "Page"]
|
||||
__all__ = ["Component", "Page", "Reactive"]
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ from pathlib import Path
|
|||
from threading import RLock
|
||||
from typing import Callable
|
||||
|
||||
ReactiveFunctions = dict[tuple[str, str], Callable[..., object]]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PageDefinition:
|
||||
|
|
@ -18,6 +20,7 @@ class PageDefinition:
|
|||
class Registry:
|
||||
def __init__(self) -> None:
|
||||
self._pages: dict[str, PageDefinition] = {}
|
||||
self._reactive: ReactiveFunctions = {}
|
||||
self._lock = RLock()
|
||||
|
||||
def register(self, page: PageDefinition) -> None:
|
||||
|
|
@ -31,6 +34,22 @@ class Registry:
|
|||
with self._lock:
|
||||
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(
|
||||
self,
|
||||
target: str | int | float | Callable[..., object],
|
||||
|
|
@ -79,13 +98,13 @@ class Registry:
|
|||
)
|
||||
return candidates[0]
|
||||
|
||||
def snapshot(self) -> dict[str, PageDefinition]:
|
||||
def snapshot(self) -> tuple[dict[str, PageDefinition], ReactiveFunctions]:
|
||||
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:
|
||||
self._pages = dict(snapshot)
|
||||
self._pages, self._reactive = snapshot
|
||||
|
||||
def remove_source(self, source_file: str | Path) -> None:
|
||||
target = str(Path(source_file).resolve())
|
||||
|
|
@ -95,11 +114,15 @@ class Registry:
|
|||
for key, page in self._pages.items()
|
||||
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:
|
||||
"""Clear all registered pages. Primarily useful in test suites."""
|
||||
with self._lock:
|
||||
self._pages.clear()
|
||||
self._reactive.clear()
|
||||
|
||||
|
||||
registry = Registry()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import runpy
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from types import ModuleType
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
import uvicorn
|
||||
|
|
@ -57,20 +57,18 @@ class DocumentationSite:
|
|||
return self._last_error
|
||||
|
||||
def _discover_sources(self) -> None:
|
||||
for page in registry.pages():
|
||||
if not page.source_file:
|
||||
continue
|
||||
path = Path(page.source_file).resolve()
|
||||
self._source_modules.setdefault(path, set()).add(page.module)
|
||||
self._source_modules.clear()
|
||||
for source_file, module in registry.sources():
|
||||
path = Path(source_file)
|
||||
self._source_modules.setdefault(path, set()).add(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:
|
||||
self._source_mtimes[path] = path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
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:
|
||||
if not self.live_preview:
|
||||
|
|
@ -88,11 +86,15 @@ class DocumentationSite:
|
|||
return False
|
||||
|
||||
snapshot = registry.snapshot()
|
||||
module_snapshots: dict[ModuleType, dict[str, object]] = {}
|
||||
try:
|
||||
for path, _ in changed:
|
||||
self._reload_source(path)
|
||||
self._reload_source(path, module_snapshots)
|
||||
new_result = self.compiler.build()
|
||||
except Exception as error:
|
||||
for module, namespace in module_snapshots.items():
|
||||
module.__dict__.clear()
|
||||
module.__dict__.update(namespace)
|
||||
registry.restore(snapshot)
|
||||
self._last_error = f"{type(error).__name__}: {error}"
|
||||
for path, mtime in changed:
|
||||
|
|
@ -103,19 +105,22 @@ class DocumentationSite:
|
|||
self._last_error = None
|
||||
for path, mtime in changed:
|
||||
self._source_mtimes[path] = mtime
|
||||
self._source_modules.clear()
|
||||
self._discover_sources()
|
||||
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())
|
||||
if not modules:
|
||||
return
|
||||
registry.remove_source(path)
|
||||
reloadable = [name for name in modules if name not in {"__main__", "<run_path>"} and name in sys.modules]
|
||||
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:
|
||||
importlib.reload(sys.modules[name])
|
||||
module = sys.modules[name]
|
||||
snapshots.setdefault(module, module.__dict__.copy())
|
||||
exec(code, module.__dict__)
|
||||
else:
|
||||
# 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))}")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import base64
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import runpy
|
||||
import sys
|
||||
import zipfile
|
||||
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"]
|
||||
|
||||
|
||||
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):
|
||||
custom_js = tmp_path / "inject.js"
|
||||
custom_js.write_text("window.customVersion = 1;", encoding="utf-8")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue