diff --git a/README.md b/README.md index 9a9e257..da2ed00 100644 --- a/README.md +++ b/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 работает с локальными исходниками проекта. diff --git a/docslib/__init__.py b/docslib/__init__.py index dc1777a..080f7c7 100644 --- a/docslib/__init__.py +++ b/docslib/__init__.py @@ -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", ] diff --git a/docslib/hooks.py b/docslib/hooks.py index d0f1c7c..28ef44d 100644 --- a/docslib/hooks.py +++ b/docslib/hooks.py @@ -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 "" 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"] diff --git a/docslib/registry.py b/docslib/registry.py index 495b74b..fca9499 100644 --- a/docslib/registry.py +++ b/docslib/registry.py @@ -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() diff --git a/docslib/server.py b/docslib/server.py index 0a42e25..43b85db 100644 --- a/docslib/server.py +++ b/docslib/server.py @@ -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__", ""} 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))}") diff --git a/tests/test_server.py b/tests/test_server.py index adef9d9..953650a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -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")