Spaces:
Paused
Paused
Commit Β·
ebaf77e
1
Parent(s): ced5ccc
Deploy QueryLens (Gradio-SDK launcher: worker :8700 / MCP :8701 / web :7860)
Browse files
app.py
CHANGED
|
@@ -1,41 +1,67 @@
|
|
| 1 |
"""
|
| 2 |
-
Hugging Face Spaces entrypoint β Gradio SDK
|
| 3 |
-
====================================================
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
| 7 |
|
| 8 |
this process (port 7860, public)
|
| 9 |
βββ Agent.worker β spawned uvicorn :8700 (Text2SQL / DocVQA backend)
|
| 10 |
βββ Agent.mcp_server β spawned uvicorn :8701 (MCP tool server)
|
| 11 |
-
βββ web.app FastAPI β
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
"""
|
| 26 |
import os
|
| 27 |
-
import
|
| 28 |
import subprocess
|
|
|
|
|
|
|
| 29 |
from pathlib import Path
|
| 30 |
|
| 31 |
ROOT = Path(__file__).resolve().parent
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
# -----------------------------------------------------------------------------
|
| 35 |
-
# Background services (same as start_hf.sh in the Docker image)
|
|
|
|
|
|
|
| 36 |
# -----------------------------------------------------------------------------
|
| 37 |
-
def
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
print(f"[LAUNCHER] starting {name} on :{port} ...", flush=True)
|
| 40 |
return subprocess.Popen(
|
| 41 |
[sys.executable, "-u", "-m", "uvicorn", module,
|
|
@@ -48,25 +74,73 @@ _worker = _spawn("Agent.worker:app", 8700, "backend worker")
|
|
| 48 |
_mcp = _spawn("Agent.mcp_server:app", 8701, "MCP tool server")
|
| 49 |
|
| 50 |
# -----------------------------------------------------------------------------
|
| 51 |
-
#
|
| 52 |
-
# so the Gradio-SDK runtime is satisfied.
|
| 53 |
# -----------------------------------------------------------------------------
|
| 54 |
import gradio as gr # noqa: E402 (preinstalled in the Gradio-SDK runtime)
|
| 55 |
from web.app import app as fastapi_app # noqa: E402
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
gr.Markdown(
|
| 59 |
"**QueryLens** β the full sepia UI is served at this Space's root `/`.\n\n"
|
| 60 |
"This page is only an anchor for the Gradio runtime."
|
| 61 |
)
|
| 62 |
gr.HTML('<a href="/" style="text-decoration:none;font-weight:600;">β Open the QueryLens UI</a>')
|
| 63 |
|
| 64 |
-
app = gr.mount_gradio_app(fastapi_app, _lp, path="/gradio-lp")
|
| 65 |
-
|
| 66 |
|
| 67 |
if __name__ == "__main__":
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"""
|
| 2 |
+
Hugging Face Spaces entrypoint β Gradio SDK on the free ZeroGPU tier
|
| 3 |
+
====================================================================
|
| 4 |
|
| 5 |
+
Free personal accounts can only host Gradio Spaces on ZeroGPU hardware
|
| 6 |
+
(CPU-basic requires a paid plan), so this launcher satisfies ZeroGPU's
|
| 7 |
+
startup contract while keeping the exact three-service architecture the
|
| 8 |
+
Docker image used:
|
| 9 |
|
| 10 |
this process (port 7860, public)
|
| 11 |
βββ Agent.worker β spawned uvicorn :8700 (Text2SQL / DocVQA backend)
|
| 12 |
βββ Agent.mcp_server β spawned uvicorn :8701 (MCP tool server)
|
| 13 |
+
βββ web.app FastAPI β grafted into Gradio's own server on :7860
|
| 14 |
+
|
| 15 |
+
ZeroGPU contract (the two failure modes cost by the first deploy):
|
| 16 |
+
1. The platform requires at least one ``@spaces.GPU``-decorated function
|
| 17 |
+
to exist at startup AND a real ``demo.launch()`` call β skipping
|
| 18 |
+
``launch()`` in favour of ``gr.mount_gradio_app`` + ``uvicorn.run``
|
| 19 |
+
triggers ``No @spaces.GPU function detected during startup``.
|
| 20 |
+
2. Because our app never uses a GPU, the decorated function below is a
|
| 21 |
+
no-op that is never called, so zero GPU quota is ever consumed.
|
| 22 |
+
|
| 23 |
+
Serving strategy (proven pattern for FastAPI-on-ZeroGPU spaces):
|
| 24 |
+
- ``demo.launch(server_port=7860, prevent_thread_lock=True,
|
| 25 |
+
ssr_mode=False)`` makes Gradio own the public port.
|
| 26 |
+
- Our full FastAPI web app (sepia UI + /api proxies) is then grafted
|
| 27 |
+
into ``demo.app.router.routes`` as a Mount inserted in FRONT of
|
| 28 |
+
Gradio's routes, so ``/`` serves the sepia UI (Gradio also registers
|
| 29 |
+
``get /`` and ``get /static/{path}``, which would otherwise shadow it).
|
| 30 |
+
- ``ssr_mode=False`` is mandatory: Spaces' Node SSR proxy intercepts
|
| 31 |
+
every request it does not recognise as Gradio-owned and never
|
| 32 |
+
forwards it to Python.
|
| 33 |
+
- Mounted sub-apps do not receive lifespan events, so the shared
|
| 34 |
+
httpx client of ``web.app`` is created eagerly after launch.
|
| 35 |
+
|
| 36 |
+
Outside ZeroGPU (local dev / Docker), the previous behaviour is kept:
|
| 37 |
+
``gr.mount_gradio_app`` + ``uvicorn.run``.
|
| 38 |
"""
|
| 39 |
import os
|
| 40 |
+
import socket
|
| 41 |
import subprocess
|
| 42 |
+
import sys
|
| 43 |
+
import time
|
| 44 |
from pathlib import Path
|
| 45 |
|
| 46 |
ROOT = Path(__file__).resolve().parent
|
| 47 |
+
PORT = int(os.environ.get("PORT", "7860"))
|
| 48 |
|
| 49 |
|
| 50 |
# -----------------------------------------------------------------------------
|
| 51 |
+
# Background services (same as start_hf.sh in the Docker image).
|
| 52 |
+
# Port-guarded: the Spaces runtime can re-execute this script inside the same
|
| 53 |
+
# container, and duplicate uvicorn children would just exit with bind errors.
|
| 54 |
# -----------------------------------------------------------------------------
|
| 55 |
+
def _listening(port: int) -> bool:
|
| 56 |
+
with socket.socket() as s:
|
| 57 |
+
s.settimeout(0.25)
|
| 58 |
+
return s.connect_ex(("127.0.0.1", port)) == 0
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _spawn(module: str, port: int, name: str):
|
| 62 |
+
if _listening(port):
|
| 63 |
+
print(f"[LAUNCHER] {name} already listening on :{port} β skipping spawn", flush=True)
|
| 64 |
+
return None
|
| 65 |
print(f"[LAUNCHER] starting {name} on :{port} ...", flush=True)
|
| 66 |
return subprocess.Popen(
|
| 67 |
[sys.executable, "-u", "-m", "uvicorn", module,
|
|
|
|
| 74 |
_mcp = _spawn("Agent.mcp_server:app", 8701, "MCP tool server")
|
| 75 |
|
| 76 |
# -----------------------------------------------------------------------------
|
| 77 |
+
# Gradio shell + ZeroGPU contract
|
|
|
|
| 78 |
# -----------------------------------------------------------------------------
|
| 79 |
import gradio as gr # noqa: E402 (preinstalled in the Gradio-SDK runtime)
|
| 80 |
from web.app import app as fastapi_app # noqa: E402
|
| 81 |
|
| 82 |
+
try:
|
| 83 |
+
import spaces # noqa: E402 (provided by the HF runtime; absent locally)
|
| 84 |
+
_ZERO_GPU = bool(getattr(getattr(spaces, "config", None), "zero_gpu", False))
|
| 85 |
+
except Exception:
|
| 86 |
+
spaces = None
|
| 87 |
+
_ZERO_GPU = False
|
| 88 |
+
|
| 89 |
+
if spaces is not None:
|
| 90 |
+
@spaces.GPU
|
| 91 |
+
def _zerogpu_platform_check():
|
| 92 |
+
"""Never called. HF's ZeroGPU tier statically requires at least one
|
| 93 |
+
@spaces.GPU-decorated function to exist at startup, or it kills the
|
| 94 |
+
container. The decorator only allocates a GPU when the function
|
| 95 |
+
actually runs, so this costs zero quota for our CPU-only app."""
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
with gr.Blocks(title="QueryLens") as _lp:
|
| 99 |
gr.Markdown(
|
| 100 |
"**QueryLens** β the full sepia UI is served at this Space's root `/`.\n\n"
|
| 101 |
"This page is only an anchor for the Gradio runtime."
|
| 102 |
)
|
| 103 |
gr.HTML('<a href="/" style="text-decoration:none;font-weight:600;">β Open the QueryLens UI</a>')
|
| 104 |
|
|
|
|
|
|
|
| 105 |
|
| 106 |
if __name__ == "__main__":
|
| 107 |
+
if _ZERO_GPU:
|
| 108 |
+
import httpx
|
| 109 |
+
import web.app as _webapp
|
| 110 |
+
from starlette.routing import Mount
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
# Gradio owns the public port; prevent_thread_lock returns control
|
| 114 |
+
# so we can graft the web app below, then block_thread holds the
|
| 115 |
+
# main thread (what launch() would have done).
|
| 116 |
+
_lp.launch(
|
| 117 |
+
server_name="0.0.0.0",
|
| 118 |
+
server_port=PORT,
|
| 119 |
+
prevent_thread_lock=True,
|
| 120 |
+
ssr_mode=False,
|
| 121 |
+
)
|
| 122 |
+
except OSError as exc:
|
| 123 |
+
# The Spaces runtime re-executed this script while a first copy is
|
| 124 |
+
# already serving β do not touch the port, just idle forever.
|
| 125 |
+
print(f"[LAUNCHER] port {PORT} already held by the first instance ({exc}); idling", flush=True)
|
| 126 |
+
while True:
|
| 127 |
+
time.sleep(3600)
|
| 128 |
+
|
| 129 |
+
# Mounted sub-apps do not receive startup events β initialise the
|
| 130 |
+
# shared httpx client eagerly so /api proxies never see client=None.
|
| 131 |
+
if _webapp.client is None:
|
| 132 |
+
_webapp.client = httpx.AsyncClient(timeout=_webapp.WORKER_TIMEOUT)
|
| 133 |
+
|
| 134 |
+
# Serve the full web app in front of Gradio's own routes: the visible
|
| 135 |
+
# UI (root /) becomes the sepia web app, /api/* proxies work as-is.
|
| 136 |
+
_lp.app.router.routes.insert(0, Mount("/", app=fastapi_app, name="querylens_web"))
|
| 137 |
+
print(f"[LAUNCHER] QueryLens web UI live on http://0.0.0.0:{PORT}", flush=True)
|
| 138 |
+
try:
|
| 139 |
+
_lp.block_thread()
|
| 140 |
+
except KeyboardInterrupt:
|
| 141 |
+
pass
|
| 142 |
+
else:
|
| 143 |
+
app = gr.mount_gradio_app(fastapi_app, _lp, path="/gradio-lp")
|
| 144 |
+
print(f"[LAUNCHER] QueryLens web UI live on http://0.0.0.0:{PORT}", flush=True)
|
| 145 |
+
import uvicorn
|
| 146 |
+
uvicorn.run(app, host="0.0.0.0", port=PORT)
|