from __future__ import annotations import importlib import json import runpy import sys from pathlib import Path from threading import RLock from typing import Mapping, Sequence import uvicorn from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse, Response from .compiler import BuildResult, Compiler from .registry import registry from .ui import HeaderButton def _manifest_url(manifest_path: str | None, manifest_parh: str | None) -> str: if manifest_path and manifest_parh: raise ValueError("Use either manifest_path or the legacy typo manifest_parh, not both") value = manifest_path or manifest_parh or "./manifest.json" if value.endswith(".json"): return value return value.rstrip("/") + "/manifest.json" class DocumentationSite: def __init__(self, compiler: Compiler, *, live_preview: bool) -> None: self.compiler = compiler self.live_preview = live_preview self._lock = RLock() self._result = compiler.build() self._last_error: str | None = None self._source_modules: dict[Path, set[str]] = {} self._source_mtimes: dict[Path, int] = {} self._discover_sources() @property def result(self) -> BuildResult: with self._lock: return self._result @property def last_error(self) -> str | None: with self._lock: 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) 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: return False with self._lock: changed = [] for path, previous in self._source_mtimes.items(): try: current = path.stat().st_mtime_ns except OSError: continue if current != previous: changed.append((path, current)) if not changed: return False snapshot = registry.snapshot() try: for path, _ in changed: self._reload_source(path) new_result = self.compiler.build() except Exception as error: registry.restore(snapshot) self._last_error = f"{type(error).__name__}: {error}" for path, mtime in changed: self._source_mtimes[path] = mtime return False self._result = new_result 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: 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: for name in reloadable: importlib.reload(sys.modules[name]) 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))}") def create_app( *, title: str = "Documentation", assets_dir: str | Path = "./assets", custom_css: str | Path | None = None, custom_js: str | Path | None = None, template_dir: str | Path | None = None, manifest_path: str | None = None, manifest_parh: str | None = None, live_preview: bool = False, header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, cors_origins: Sequence[str] = ("*",), ) -> FastAPI: """Compile registered pages and return a ready-to-serve FastAPI application.""" manifest_url = _manifest_url(manifest_path, manifest_parh) compiler = Compiler( title=title, assets_dir=assets_dir, custom_css=custom_css, custom_js=custom_js, template_dir=template_dir, manifest_url=manifest_url, live_preview=live_preview, header_buttons=header_buttons, ) site = DocumentationSite(compiler, live_preview=live_preview) app = FastAPI(title=f"{title} ยท SharedDocsLib", docs_url=None, redoc_url=None) app.state.docslib = site app.add_middleware( CORSMiddleware, allow_origins=list(cors_origins), allow_credentials=False, allow_methods=["GET"], allow_headers=["*"], ) @app.get("/", include_in_schema=False) @app.get("/index.html", response_class=HTMLResponse) def index() -> HTMLResponse: return HTMLResponse(site.result.html) @app.get("/manifest.json", response_class=JSONResponse) def manifest() -> JSONResponse: return JSONResponse(site.result.manifest, headers={"Cache-Control": "no-store"}) @app.get("/index.zip") def index_zip() -> Response: return Response( site.result.zip_archive, media_type="application/zip", headers={ "Content-Disposition": 'attachment; filename="index.zip"', "Cache-Control": "no-store", }, ) if live_preview: @app.get("/live") def live() -> JSONResponse: changed = site.refresh_changed_sources() result = site.result return JSONResponse( { "enabled": True, "changed": changed, "revision": result.manifest["revision"], "updated_at": result.manifest["updated_at"], "error": site.last_error, }, headers={"Cache-Control": "no-store"}, ) return app def build( *, build_directory: str | Path = "./build", title: str = "Documentation", assets_dir: str | Path = "./assets", custom_css: str | Path | None = None, custom_js: str | Path | None = None, template_dir: str | Path | None = None, manifest_path: str | None = None, manifest_parh: str | None = None, header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, ) -> BuildResult: """Compile registered pages and write a static documentation bundle.""" compiler = Compiler( title=title, assets_dir=assets_dir, custom_css=custom_css, custom_js=custom_js, template_dir=template_dir, manifest_url=_manifest_url(manifest_path, manifest_parh), live_preview=False, header_buttons=header_buttons, ) result = compiler.build() output = Path(build_directory).expanduser().resolve() output.mkdir(parents=True, exist_ok=True) (output / "index.html").write_bytes(result.html) (output / "manifest.json").write_text( json.dumps(result.manifest, ensure_ascii=False, separators=(",", ":")), encoding="utf-8", ) (output / "index.zip").write_bytes(result.zip_archive) return result def run( *, port: int = 8000, host: str = "127.0.0.1", title: str = "Documentation", assets_dir: str | Path = "./assets", custom_css: str | Path | None = None, custom_js: str | Path | None = None, template_dir: str | Path | None = None, manifest_path: str | None = None, manifest_parh: str | None = None, live_preview: bool = False, header_buttons: Sequence[HeaderButton | Mapping[str, object]] | None = None, cors_origins: Sequence[str] = ("*",), log_level: str = "info", ) -> None: """Compile the documentation and run it with Uvicorn.""" app = create_app( title=title, assets_dir=assets_dir, custom_css=custom_css, custom_js=custom_js, template_dir=template_dir, manifest_path=manifest_path, manifest_parh=manifest_parh, live_preview=live_preview, header_buttons=header_buttons, cors_origins=cors_origins, ) uvicorn.run(app, host=host, port=port, log_level=log_level) __all__ = ["DocumentationSite", "build", "create_app", "run"]