mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 18:18:08 +00:00
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.
This commit is contained in:
1 parent
0c348917b1
commit
43f493b926
15 files changed
+419
-39
No files matched your search
+22
-11
@@ -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)
|
||||
|
||||
@@ -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('<html><head><script src="/config.js"></script></head><body>ui</body></html>')
|
||||
(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
|
||||
Reference in new issue
Block a user