From 43f493b9264e56293a69a482f7d87cab7d0551ae Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 9 Sep 2026 11:09:09 +0100 Subject: [PATCH] feat(package): ship the web UI in the wheel and serve it from the API pip install docsgpt now brings the web UI with it: `docsgpt api` serves the API and the UI on one port. - scripts/build_frontend.sh builds the frontend into docsgpt/static (gitignored) the way the frontend image does: .env.development as the production baseline, and index.html loading /config.js ahead of the bundle. hatch admits the directory into the wheel and the sdist through `artifacts`; the package workflows run the script before `uv build` and fail if the wheel lacks the UI. The backend image keeps ignoring it. - docsgpt/ui.py serves the build in front of Flask: files as they are, hashed assets immutable, Flask's own path prefixes (taken from its URL map, so new blueprints need no registration) passed through, every other GET rendered as index.html for the client-side router. /config.js is generated per request with VITE_API_HOST and VITE_BASE_URL set to the page's origin, VITE_* environment variables winning. SERVE_UI=false leaves the API alone. - docsgpt api configures gunicorn in code (gunicorn.app.base.Application) instead of rewriting sys.argv, so the SIGUSR2 re-exec that gunicorn uses for zero-downtime upgrades runs the docsgpt console script again and works; verified with a live handover. - Docs: the pip page says the UI is included, that DOCSGPT_HOME and DOCSGPT_ENV_FILE are process environment variables rather than .env entries, and the settings page describes SERVE_UI. --- .dockerignore | 2 + .github/workflows/package-build.yml | 31 +++++ .github/workflows/pypi-publish.yml | 26 +++++ .gitignore | 2 + AGENTS.md | 5 +- docs/content/Deploying/DocsGPT-Settings.mdx | 2 + docs/content/Deploying/Pip-Install.mdx | 8 +- docsgpt/asgi.py | 10 +- docsgpt/cli.py | 63 +++++++--- docsgpt/core/settings.py | 2 + docsgpt/ui.py | 108 +++++++++++++++++ pyproject.toml | 10 +- scripts/build_frontend.sh | 33 ++++++ tests/test_cli.py | 33 ++++-- tests/test_ui.py | 123 ++++++++++++++++++++ 15 files changed, 419 insertions(+), 39 deletions(-) create mode 100644 docsgpt/ui.py create mode 100755 scripts/build_frontend.sh create mode 100644 tests/test_ui.py diff --git a/.dockerignore b/.dockerignore index 0521228c..fd316071 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,3 +23,5 @@ docsgpt/*.pkl docsgpt/.env docsgpt/.env.* docsgpt/Dockerfile +# The backend image serves no UI (the frontend image does); a local UI build stays out. +docsgpt/static/ diff --git a/.github/workflows/package-build.yml b/.github/workflows/package-build.yml index f6cd3296..0be880db 100644 --- a/.github/workflows/package-build.yml +++ b/.github/workflows/package-build.yml @@ -12,6 +12,8 @@ on: - 'docsgpt/**' - 'README.md' - 'LICENSE' + - 'frontend/**' + - 'scripts/build_frontend.sh' - '.github/workflows/package-build.yml' push: branches: [main] @@ -21,6 +23,8 @@ on: - 'docsgpt/**' - 'README.md' - 'LICENSE' + - 'frontend/**' + - 'scripts/build_frontend.sh' - '.github/workflows/package-build.yml' workflow_dispatch: @@ -37,6 +41,17 @@ jobs: - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build the web UI into the package + env: + CI: "true" + run: bash scripts/build_frontend.sh + - name: Build the sdist and wheel run: uv build @@ -73,6 +88,21 @@ jobs: print(f"wheel ok: {len(names)} files") PY + - name: The wheel carries the web UI and no generated runtime config + run: | + python3 - <<'PY' + import glob + import zipfile + + wheel = zipfile.ZipFile(glob.glob("dist/*.whl")[0]) + names = set(wheel.namelist()) + assert "docsgpt/static/index.html" in names, "the UI build is missing from the wheel" + assert any(n.startswith("docsgpt/static/assets/") for n in names), "no bundle under docsgpt/static/assets/" + assert "docsgpt/static/config.js" not in names, "config.js is generated by the API at runtime" + assert b'src="/config.js"' in wheel.read("docsgpt/static/index.html"), "index.html must load /config.js" + print("ui ok:", sum(n.startswith("docsgpt/static/") for n in names), "files") + PY + - name: Install the wheel into a clean environment and run the command run: | uv venv "$RUNNER_TEMP/docsgpt-venv" @@ -81,6 +111,7 @@ jobs: "$RUNNER_TEMP/docsgpt-venv/bin/docsgpt" --help "$RUNNER_TEMP/docsgpt-venv/bin/docsgpt" api --help "$RUNNER_TEMP/docsgpt-venv/bin/python" -c "from docsgpt.core import paths; print('home:', paths.home_dir())" + "$RUNNER_TEMP/docsgpt-venv/bin/python" -c "from docsgpt.core.paths import package_dir; assert (package_dir() / 'static' / 'index.html').is_file(); print('ui installed')" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 12adcf9b..9037f758 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -59,9 +59,35 @@ jobs: echo "::error::unknown target '$TARGET': expected pypi or testpypi" exit 1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + # A publishing workflow must not restore a cache another job could have poisoned. + package-manager-cache: false + + - name: Build the web UI into the package + env: + CI: "true" + run: bash scripts/build_frontend.sh + - name: Build the sdist and wheel run: uv build + - name: The wheel carries the web UI and no generated runtime config + run: | + python3 - <<'PY' + import glob + import zipfile + + wheel = zipfile.ZipFile(glob.glob("dist/*.whl")[0]) + names = set(wheel.namelist()) + assert "docsgpt/static/index.html" in names, "the UI build is missing from the wheel" + assert any(n.startswith("docsgpt/static/assets/") for n in names), "no bundle under docsgpt/static/assets/" + assert "docsgpt/static/config.js" not in names, "config.js is generated by the API at runtime" + assert b'src="/config.js"' in wheel.read("docsgpt/static/index.html"), "index.html must load /config.js" + print("ui ok:", sum(n.startswith("docsgpt/static/") for n in names), "files") + PY + - name: The archives carry the released version if: env.RELEASE_VERSION != '' run: | diff --git a/.gitignore b/.gitignore index 8693a33e..f9e72c46 100644 --- a/.gitignore +++ b/.gitignore @@ -181,6 +181,8 @@ frontend/*.sw? docsgpt/vectors/ application/vectors/ +# The web UI build the package ships (scripts/build_frontend.sh) +docsgpt/static/ **/inputs diff --git a/AGENTS.md b/AGENTS.md index d096cf22..b8aeb247 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,10 @@ The backend is also an installable package (`pyproject.toml`, hatchling). `docsgpt prefetch-models`, `docsgpt verify-offline`. Runtime data (`.env`, `inputs/`, `indexes/`) lives in the checkout by default; `DOCSGPT_HOME` moves that data home, and `DOCSGPT_ENV_FILE` selects only the `.env` file (see -`docsgpt/core/paths.py`). +`docsgpt/core/paths.py`). `bash scripts/build_frontend.sh` builds the web UI +into `docsgpt/static` (gitignored); the API serves it when present +(`docsgpt/ui.py`, switch `SERVE_UI`), and the package workflows run the script +before `uv build` so the wheel ships it. Dependencies are declared in `pyproject.toml` and locked in `uv.lock`; the `docsgpt/requirements*.txt` files are exported from the lock. To add or diff --git a/docs/content/Deploying/DocsGPT-Settings.mdx b/docs/content/Deploying/DocsGPT-Settings.mdx index 6ac584a7..61d30483 100644 --- a/docs/content/Deploying/DocsGPT-Settings.mdx +++ b/docs/content/Deploying/DocsGPT-Settings.mdx @@ -69,6 +69,8 @@ Here are some of the most fundamental settings you'll likely want to configure: - **`STT_PROVIDER`**: Selects the speech-to-text provider used for microphone transcription in chat and for audio file ingestion through the parser pipeline. +- **`SERVE_UI`**: Whether the API process serves the web UI shipped in the Python package (`pip install docsgpt`). Defaults to `true`; set `false` when a separate frontend, such as the Docker frontend image, serves the UI. Has no effect in the backend Docker image, which carries no UI build. + ## Configuration Examples Let's look at some concrete examples of how to configure these settings in your `.env` file. diff --git a/docs/content/Deploying/Pip-Install.mdx b/docs/content/Deploying/Pip-Install.mdx index 8da950d3..dbfa604b 100644 --- a/docs/content/Deploying/Pip-Install.mdx +++ b/docs/content/Deploying/Pip-Install.mdx @@ -7,10 +7,10 @@ import { Callout } from 'nextra/components' # Install with pip -The DocsGPT backend is on PyPI as [`docsgpt`](https://pypi.org/project/docsgpt/): the API server, the Celery worker and the maintenance scripts in one package, behind a single `docsgpt` command. Use it when you want DocsGPT inside your own Python environment or process manager rather than the [Docker images](/Deploying/Docker-Deploying). +DocsGPT is on PyPI as [`docsgpt`](https://pypi.org/project/docsgpt/): the API server, the web UI, the Celery worker and the maintenance scripts in one package, behind a single `docsgpt` command. Use it when you want DocsGPT inside your own Python environment or process manager rather than the [Docker images](/Deploying/Docker-Deploying). - The package is the backend only. The web UI ships in the Docker images and in the repository under `frontend/`; serving it from the package is planned. + `docsgpt api` serves the web UI on the same port as the API. Set `SERVE_UI=false` to run the API alone, for example behind the frontend Docker image or a UI you host yourself. ## Requirements @@ -49,7 +49,7 @@ pipx runpip docsgpt install --force-reinstall --no-deps --index-url https://down ## Configure -DocsGPT keeps its runtime files in a **data home**: the `.env` file it reads settings from, uploaded files under `inputs/` and vector indexes under `indexes/`. The data home is the directory you run the commands from, or the directory `DOCSGPT_HOME` points to. `DOCSGPT_ENV_FILE` points at a `.env` kept somewhere else. +DocsGPT keeps its runtime files in a **data home**: the `.env` file it reads settings from, uploaded files under `inputs/` and vector indexes under `indexes/`. The data home is the directory you run the commands from, or the directory `DOCSGPT_HOME` points to. `DOCSGPT_ENV_FILE` points at a `.env` kept somewhere else. Both variables must be set in the process environment, not in `.env`: they decide where `.env` is read from. Create a `.env` in the data home. The minimum for a hosted LLM: @@ -72,7 +72,7 @@ Redis defaults to `localhost:6379` (`CELERY_BROKER_URL`, `CELERY_RESULT_BACKEND` ```bash docsgpt migrate # create the database if it is missing and apply the migrations -docsgpt api # the API on http://127.0.0.1:7091 +docsgpt api # the API and the web UI on http://127.0.0.1:7091 docsgpt worker # in a second terminal: the Celery worker, with the scheduler ``` diff --git a/docsgpt/asgi.py b/docsgpt/asgi.py index dd60d4fe..e694fefc 100644 --- a/docsgpt/asgi.py +++ b/docsgpt/asgi.py @@ -12,11 +12,19 @@ from docsgpt.api.async_sse import async_sse_routes from docsgpt.app import app as flask_app from docsgpt.core.settings import settings from docsgpt.mcp_server import mcp +from docsgpt.ui import StaticUI _WSGI_THREADPOOL = int(settings.WSGI_THREADPOOL_WORKERS) mcp_app = mcp.http_app(path="/") +# The web UI, when the package ships one (docsgpt/static) and SERVE_UI is on: +# files are served directly, Flask's own path prefixes pass through, and any +# other GET renders index.html for the client-side router. +_backend = StaticUI.wrap( + WSGIMiddleware(flask_app, workers=_WSGI_THREADPOOL), flask_app.url_map, enabled=settings.SERVE_UI +) + asgi_app = Starlette( routes=[ Mount("/mcp", app=mcp_app), @@ -26,7 +34,7 @@ asgi_app = Starlette( # Starlette matches routes top-to-bottom, so these must precede the # Mount("/") that hands everything else to Flask. *async_sse_routes, - Mount("/", app=WSGIMiddleware(flask_app, workers=_WSGI_THREADPOOL)), + Mount("/", app=_backend), ], middleware=[ Middleware( diff --git a/docsgpt/cli.py b/docsgpt/cli.py index 04fe1960..e9d4d670 100644 --- a/docsgpt/cli.py +++ b/docsgpt/cli.py @@ -25,6 +25,49 @@ def _announce_home() -> None: print(f"docsgpt: data home {home_dir()} (env file {env_file()})", file=sys.stderr) +def _gunicorn_options(host: str, port: int, workers: int) -> dict: + """The image's gunicorn flags (docsgpt/Dockerfile CMD), as settings.""" + options = { + "bind": f"{host}:{port}", + "workers": workers, + "worker_class": "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker", + "timeout": 180, + "graceful_timeout": 120, + "keepalive": 5, + "max_requests": 5000, + "max_requests_jitter": 500, + } + if os.path.isdir("/dev/shm"): + options["worker_tmp_dir"] = "/dev/shm" + return options + + +def _gunicorn_application(options: dict): + """A gunicorn application configured in code rather than from sys.argv. + + gunicorn records sys.argv at start and re-executes it on SIGUSR2 (the + zero-downtime upgrade), so sys.argv has to stay the ``docsgpt api ...`` + invocation: the console script is what a re-exec must run again. + """ + from gunicorn.app.base import Application + + class DocsGPTApplication(Application): + def init(self, parser, opts, args): + return None + + def load_config(self): + self.load_config_from_module_name_or_filename("python:docsgpt.gunicorn_conf") + for key, value in options.items(): + self.cfg.set(key, value) + + def load(self): + from docsgpt.asgi import asgi_app + + return asgi_app + + return DocsGPTApplication() + + def _api(args: argparse.Namespace) -> int: """Serve the ASGI app: gunicorn with the bounded-drain uvicorn worker, or uvicorn when reloading.""" _announce_home() @@ -34,25 +77,7 @@ def _api(args: argparse.Namespace) -> int: uvicorn.run("docsgpt.asgi:asgi_app", host=args.host, port=args.port, reload=args.reload) return 0 - from gunicorn.app.wsgiapp import run as gunicorn_run - - argv = [ - "gunicorn", - "-w", str(args.workers), - "-k", "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker", - "--bind", f"{args.host}:{args.port}", - "--timeout", "180", - "--graceful-timeout", "120", - "--keep-alive", "5", - "--max-requests", "5000", - "--max-requests-jitter", "500", - "--config", "python:docsgpt.gunicorn_conf", - ] - if os.path.isdir("/dev/shm"): - argv += ["--worker-tmp-dir", "/dev/shm"] - argv.append("docsgpt.asgi:asgi_app") - sys.argv = argv - gunicorn_run() + _gunicorn_application(_gunicorn_options(args.host, args.port, args.workers)).run() return 0 diff --git a/docsgpt/core/settings.py b/docsgpt/core/settings.py index f926485b..14c834b2 100644 --- a/docsgpt/core/settings.py +++ b/docsgpt/core/settings.py @@ -107,6 +107,8 @@ class Settings(BaseSettings): "request_limit": 500, } UPLOAD_FOLDER: str = "inputs" + # Serve the web UI shipped in the package (docsgpt/static) from the API process. + SERVE_UI: bool = True # Request cap is applied by Flask before multipart parsing; the per-file cap also while copying. UPLOAD_MAX_REQUEST_BYTES: int = Field(default=256 * 1024 * 1024, gt=0) UPLOAD_MAX_FILE_BYTES: int = Field(default=100 * 1024 * 1024, gt=0) diff --git a/docsgpt/ui.py b/docsgpt/ui.py new file mode 100644 index 00000000..04a951e4 --- /dev/null +++ b/docsgpt/ui.py @@ -0,0 +1,108 @@ +"""Serve the built web UI from the API process. + +The wheel ships the frontend build under ``docsgpt/static`` (produced by +``scripts/build_frontend.sh``). When that directory holds an ``index.html`` +and ``SERVE_UI`` is on, the ASGI shell puts :class:`StaticUI` in front of +Flask: files are served as they are, paths that belong to the backend pass +through, and every other GET renders ``index.html`` for the client-side +router. ``/config.js`` is generated per request so the UI talks to the origin +it was loaded from, the same mechanism the nginx image uses. +""" + +from __future__ import annotations + +import json +import logging +import os +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Optional + +from starlette.responses import FileResponse, Response +from starlette.types import ASGIApp, Receive, Scope, Send + +from docsgpt.core.paths import package_dir + +logger = logging.getLogger(__name__) + +# Runtime values the page reads before the bundle loads. These default to the +# origin the page came from; a VITE_* variable in the process environment +# wins, so a deployment can still point the UI somewhere else. +_ORIGIN_KEYS = ("VITE_API_HOST", "VITE_BASE_URL") + +# Types Python's mimetypes table may not know. +_MEDIA_TYPES = {".woff2": "font/woff2", ".woff": "font/woff", ".webmanifest": "application/manifest+json"} + +_IMMUTABLE = "public, max-age=31536000, immutable" +_NO_CACHE = "no-cache" + + +def config_js(environ: Optional[Mapping[str, str]] = None) -> str: + """The ``/config.js`` body: ``window.__DOCSGPT_ENV__`` from the VITE_* environment.""" + env = os.environ if environ is None else environ + values = {key: value for key, value in env.items() if key.startswith("VITE_") and value} + entries = [f"{json.dumps(key)}:window.location.origin" for key in _ORIGIN_KEYS if key not in values] + entries += [f"{json.dumps(key)}:{json.dumps(value)}" for key, value in sorted(values.items())] + return "window.__DOCSGPT_ENV__={" + ",".join(entries) + "};\n" + + +class StaticUI: + """ASGI app: the built UI in front of the backend.""" + + def __init__(self, static_dir: Path, backend: ASGIApp, backend_prefixes: Iterable[str]) -> None: + self.static_dir = Path(static_dir).resolve() + self.index = self.static_dir / "index.html" + self.backend = backend + self.backend_prefixes = frozenset(backend_prefixes) + + @classmethod + def wrap(cls, backend: ASGIApp, url_map, static_dir: Optional[Path] = None, enabled: bool = True) -> ASGIApp: + """``backend`` alone when there is no UI to serve (or it is switched off), else the UI in front of it. + + The backend's path prefixes come from Flask's URL map, so a new + blueprint never needs registering here: any first path segment Flask + routes stays Flask's, everything else is the UI's. + """ + static_dir = Path(static_dir) if static_dir is not None else package_dir() / "static" + if not enabled or not (static_dir / "index.html").is_file(): + return backend + prefixes = {rule.rule.split("/")[1] for rule in url_map.iter_rules() if rule.rule != "/"} + logger.info("Serving the web UI from %s", static_dir) + return cls(static_dir, backend, prefixes) + + def _file(self, path: str) -> Optional[Path]: + """The file under the static directory for ``path``, or None (never a path outside it).""" + relative = path.lstrip("/") + if not relative: + return None + try: + resolved = (self.static_dir / relative).resolve() + except OSError: + return None + if self.static_dir not in resolved.parents: + return None + return resolved if resolved.is_file() else None + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope["method"] not in ("GET", "HEAD"): + await self.backend(scope, receive, send) + return + path = scope["path"] + root = scope.get("root_path", "") + if root and path.startswith(root): + path = path[len(root):] or "/" + if path == "/config.js": + response = Response(config_js(), media_type="application/javascript", headers={"Cache-Control": "no-store"}) + await response(scope, receive, send) + return + first = path.split("/")[1] if len(path) > 1 else "" + if first in self.backend_prefixes: + await self.backend(scope, receive, send) + return + file = self._file(path) + if file is None: + file, cache = self.index, _NO_CACHE + else: + cache = _IMMUTABLE if first == "assets" else _NO_CACHE + response = FileResponse(file, media_type=_MEDIA_TYPES.get(file.suffix), headers={"Cache-Control": cache}) + await response(scope, receive, send) diff --git a/pyproject.toml b/pyproject.toml index 99fcf8a0..e1788ccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,11 +170,14 @@ dev = [ path = "docsgpt/version.py" # The wheel is the docsgpt package with the data it reads at runtime: prompts, -# model catalogs, the seed config, alembic.ini and the migrations. Build inputs -# (Dockerfile, exported requirements), the sample index and local runtime data -# stay out. The one-release `application` import alias is checkout-only. +# model catalogs, the seed config, alembic.ini and the migrations, plus the web +# UI build under docsgpt/static (gitignored; scripts/build_frontend.sh makes +# it, and `artifacts` admits it). Build inputs (Dockerfile, exported +# requirements), the sample index and local runtime data stay out. The +# one-release `application` import alias is checkout-only. [tool.hatch.build.targets.wheel] packages = ["docsgpt"] +artifacts = ["docsgpt/static/**"] exclude = [ "docsgpt/Dockerfile", "docsgpt/requirements*.txt", @@ -187,6 +190,7 @@ exclude = [ [tool.hatch.build.targets.sdist] include = ["/docsgpt"] +artifacts = ["docsgpt/static/**"] exclude = [ "docsgpt/Dockerfile", "docsgpt/requirements*.txt", diff --git a/scripts/build_frontend.sh b/scripts/build_frontend.sh new file mode 100755 index 00000000..294d2b11 --- /dev/null +++ b/scripts/build_frontend.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Build the web UI into docsgpt/static so the Python package ships it. +# +# The API serves that directory when it exists (docsgpt/ui.py). The package +# workflows run this before `uv build`; run it locally to test the served UI +# or to build a wheel with the UI in it. +set -euo pipefail + +cd "$(dirname "$0")/../frontend" + +if [ ! -d node_modules ] || [ "${CI:-}" = "true" ]; then + npm ci --include=dev +fi + +if [ -f .env.local ]; then + echo "note: frontend/.env.local exists; its VITE_* values are baked into this build" >&2 +fi + +# As in frontend/Dockerfile: the committed .env.development is the baseline +# of the production build. The API rewrites VITE_API_HOST and VITE_BASE_URL +# at runtime through /config.js, so those baked values never reach a browser. +cp .env.development .env.production.local +trap 'rm -f .env.production.local' EXIT +npm run build + +# Load the runtime config ahead of the bundle, as the nginx image does. +sed -i.bak 's|||' dist/index.html +rm -f dist/index.html.bak +grep -q 'src="/config.js"' dist/index.html + +rm -rf ../docsgpt/static +cp -R dist ../docsgpt/static +echo "built docsgpt/static ($(du -sh ../docsgpt/static | cut -f1))" diff --git a/tests/test_cli.py b/tests/test_cli.py index af60c7df..c8f934ce 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -35,21 +35,32 @@ class TestTopLevel: class TestApi: - def test_gunicorn_argv(self, monkeypatch, capsys): - run = MagicMock() - monkeypatch.setitem(sys.modules, "gunicorn.app.wsgiapp", types.SimpleNamespace(run=run)) + def test_gunicorn_runs_with_the_image_settings_and_leaves_argv_alone(self, monkeypatch, capsys): + application = MagicMock() + factory = MagicMock(return_value=application) + monkeypatch.setattr(cli, "_gunicorn_application", factory) monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(sys, "argv", ["docsgpt"]) + monkeypatch.setattr(sys, "argv", ["/venv/bin/docsgpt", "api", "--port", "8000"]) assert cli.main(["api", "--port", "8000", "--workers", "2"]) == 0 - run.assert_called_once_with() - argv = sys.argv - assert argv[0] == "gunicorn" and argv[-1] == "docsgpt.asgi:asgi_app" - assert argv[argv.index("--bind") + 1] == "127.0.0.1:8000" - assert argv[argv.index("-w") + 1] == "2" - assert argv[argv.index("-k") + 1] == "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker" - assert argv[argv.index("--config") + 1] == "python:docsgpt.gunicorn_conf" + application.run.assert_called_once_with() + options = factory.call_args.args[0] + assert options["bind"] == "127.0.0.1:8000" + assert options["workers"] == 2 + assert options["worker_class"] == "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker" + assert options["max_requests"] == 5000 + # gunicorn re-executes sys.argv on SIGUSR2; it must still be the docsgpt invocation. + assert sys.argv == ["/venv/bin/docsgpt", "api", "--port", "8000"] assert "data home" in capsys.readouterr().err + def test_the_gunicorn_application_carries_the_settings_and_the_log_config(self): + app = cli._gunicorn_application(cli._gunicorn_options("127.0.0.1", 8001, 3)) + assert app.cfg.bind == ["127.0.0.1:8001"] + assert app.cfg.workers == 3 + assert app.cfg.worker_class_str == "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker" + assert app.cfg.keepalive == 5 + assert app.cfg.graceful_timeout == 120 + assert "ncsa_access" in app.cfg.logconfig_dict["formatters"], "docsgpt.gunicorn_conf was loaded" + def test_reload_uses_uvicorn(self, monkeypatch): uvicorn = types.SimpleNamespace(run=MagicMock()) monkeypatch.setitem(sys.modules, "uvicorn", uvicorn) diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 00000000..4a1045dd --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,123 @@ +"""The web UI shipped in the package is served in front of the API (docsgpt.ui).""" + +import pytest +from a2wsgi import WSGIMiddleware +from flask import Flask +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.testclient import TestClient + +from docsgpt.ui import StaticUI, config_js + + +@pytest.fixture +def static_dir(tmp_path): + (tmp_path / "index.html").write_text('ui') + (tmp_path / "assets").mkdir() + (tmp_path / "assets" / "index-abc123.js").write_text("console.log('bundle')") + (tmp_path / "fonts").mkdir() + (tmp_path / "fonts" / "inter.woff2").write_bytes(b"\x00\x01") + (tmp_path / "favicon.ico").write_bytes(b"\x00") + return tmp_path + + +def _backend() -> Flask: + app = Flask("backend") + + @app.route("/") + def home(): + return "backend home" + + @app.route("/api/health") + def health(): + return {"status": "ok"} + + @app.route("/api/echo", methods=["POST"]) + def echo(): + return "posted" + + return app + + +@pytest.fixture +def client(static_dir): + flask_app = _backend() + ui = StaticUI.wrap(WSGIMiddleware(flask_app), flask_app.url_map, static_dir=static_dir) + assert isinstance(ui, StaticUI) + return TestClient(Starlette(routes=[Mount("/", app=ui)])) + + +class TestRouting: + def test_root_is_the_ui_not_the_backend_home(self, client): + response = client.get("/") + assert response.status_code == 200 + assert "ui" in response.text and "backend home" not in response.text + assert response.headers["cache-control"] == "no-cache" + + def test_client_side_routes_render_index(self, client): + assert client.get("/agents/123/edit").text == client.get("/").text + + def test_hashed_assets_are_immutable(self, client): + response = client.get("/assets/index-abc123.js") + assert response.status_code == 200 + assert "bundle" in response.text + assert response.headers["cache-control"] == "public, max-age=31536000, immutable" + assert "javascript" in response.headers["content-type"] + + def test_font_has_a_media_type(self, client): + assert client.get("/fonts/inter.woff2").headers["content-type"] == "font/woff2" + + def test_backend_prefixes_pass_through(self, client): + assert client.get("/api/health").json() == {"status": "ok"} + missing = client.get("/api/does-not-exist") + assert missing.status_code == 404 + assert "ui" not in missing.text, "an unknown API path is the backend's 404, not the UI" + + def test_writes_never_hit_the_ui(self, client): + assert client.post("/api/echo").text == "posted" + assert client.post("/settings").status_code in (404, 405) + + def test_head_works(self, client): + response = client.head("/") + assert response.status_code == 200 + assert response.content == b"" + + +class TestConfigJs: + def test_defaults_to_the_page_origin(self, client): + response = client.get("/config.js") + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert '"VITE_API_HOST":window.location.origin' in response.text + assert '"VITE_BASE_URL":window.location.origin' in response.text + + def test_environment_values_win_and_are_escaped(self): + body = config_js({"VITE_API_HOST": "https://api.example.com", "VITE_NOTE": 'say "hi"\\', "OTHER": "x", "VITE_EMPTY": ""}) + assert '"VITE_API_HOST":"https://api.example.com"' in body + assert '"VITE_BASE_URL":window.location.origin' in body + assert '"VITE_NOTE":"say \\"hi\\"\\\\"' in body + assert "OTHER" not in body and "VITE_EMPTY" not in body + assert body.startswith("window.__DOCSGPT_ENV__={") and body.endswith("};\n") + + +class TestSafety: + def test_paths_outside_the_static_dir_are_never_served(self, static_dir): + flask_app = _backend() + ui = StaticUI.wrap(WSGIMiddleware(flask_app), flask_app.url_map, static_dir=static_dir) + (static_dir.parent / "secret.txt").write_text("nope") + assert ui._file("/../secret.txt") is None + assert ui._file("/assets/../../secret.txt") is None + assert ui._file("/") is None + assert ui._file("/assets") is None, "directories are not files" + + +class TestWrap: + def test_no_build_means_the_backend_alone(self, tmp_path): + flask_app = _backend() + backend = WSGIMiddleware(flask_app) + assert StaticUI.wrap(backend, flask_app.url_map, static_dir=tmp_path) is backend + + def test_switched_off_means_the_backend_alone(self, static_dir): + flask_app = _backend() + backend = WSGIMiddleware(flask_app) + assert StaticUI.wrap(backend, flask_app.url_map, static_dir=static_dir, enabled=False) is backend