qpyserve 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
qpyserve-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 qpyserve contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: qpyserve
3
+ Version: 0.1.0
4
+ Summary: Run FastAPI with HTML, React, or Vue on one host using one Python command
5
+ Author: qpyserve contributors
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/austinwin/qpyserve
8
+ Project-URL: Issues, https://github.com/austinwin/qpyserve/issues
9
+ Keywords: fastapi,vite,react,vue,web,asgi
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: fastapi<1,>=0.110
22
+ Requires-Dist: uvicorn[standard]<1,>=0.30
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=8; extra == "test"
25
+ Requires-Dist: httpx>=0.27; extra == "test"
26
+ Requires-Dist: build>=1.2; extra == "test"
27
+ Dynamic: license-file
28
+
29
+ # qpyserve
30
+
31
+ **FastAPI + any frontend. One Python command. One browser address.**
32
+
33
+ QPyServe is a thin bridge, not a new web framework. Write normal FastAPI endpoints and use plain HTML/JS, React, Vue, or another Vite frontend. QPyServe handles local startup, frontend dependencies, API proxying, hot reload, and static production serving.
34
+
35
+ > **Status:** Early alpha. Not yet load-tested or security-audited. Multiple workers do not guarantee performance for 1,000 concurrent users.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install qpyserve
41
+ ```
42
+
43
+ For a source checkout: `python -m pip install -e .` instead. If not yet released to PyPI, install from GitHub: `pip install git+https://github.com/austinwin/qpyserve.git` (requires repository access).
44
+
45
+ ## Hello world: two files
46
+
47
+ `main.py`:
48
+
49
+ ```python
50
+ from fastapi import FastAPI
51
+ from qpyserve import run
52
+
53
+ app = FastAPI()
54
+
55
+ @app.get('/api/hello')
56
+ def hello():
57
+ return {'message': 'Hello from Python'}
58
+
59
+ if __name__ == '__main__':
60
+ run(app, frontend='frontend', port=1111)
61
+ ```
62
+
63
+ `frontend/index.html`:
64
+
65
+ ```html
66
+ <!doctype html>
67
+ <html lang="en">
68
+ <body>
69
+ <button id="hello">Call Python</button><pre id="result"></pre>
70
+ <script>
71
+ document.querySelector('#hello').onclick = async () => {
72
+ const response = await fetch('/api/hello');
73
+ document.querySelector('#result').textContent =
74
+ JSON.stringify(await response.json());
75
+ };
76
+ </script>
77
+ </body>
78
+ </html>
79
+ ```
80
+
81
+ ```bash
82
+ python main.py
83
+ ```
84
+
85
+ Open <http://localhost:1111>. `/api/hello` serves the same origin and `/docs` shows FastAPI documentation. No Node.js, package.json, or additional commands are needed for plain HTML. Edit HTML/JS and the development page automatically refreshes.
86
+
87
+ ## React, Vue, or another Vite frontend
88
+
89
+ Point `frontend='frontend'` to an ordinary Vite project containing `package.json` with `vite` in its dependencies. **The Python code does not change.** Run `python main.py`.
90
+
91
+ - QPyServe detects npm, pnpm, Yarn, or Bun from `packageManager` / lockfiles (npm is the fallback).
92
+ - It installs missing frontend dependencies with the selected package manager, then runs the locally installed Vite. No `npm run dev` script is required.
93
+ - Existing `vite.config.js` / `.ts` / `.mjs` and plugins are retained; Vite provides normal HMR.
94
+ - Development browser URL stays `http://localhost:1111`. The API runs on loopback `:1112` and is proxied through `:1111`.
95
+ - Node.js and the chosen package manager must be installed for Vite projects. QPyServe does **not** silently install Node or fetch arbitrary runtime binaries. It reports what is missing.
96
+
97
+ See the runnable [HTML counter](examples/counter-html) and [React counter](examples/counter-react).
98
+
99
+ ## Production and builds
100
+
101
+ | Command | Behavior |
102
+ | --- | --- |
103
+ | `python main.py` | Start dev server, HMR/reload; install frontend packages if needed |
104
+ | `python main.py --build` | Install packages if needed, build frontend, and exit |
105
+ | `python main.py --prod` | Serve prebuilt frontend and FastAPI on one port |
106
+ | `python main.py --prod --build` | Build frontend first, then serve it in production mode |
107
+
108
+ Plain HTML needs no build. For Vite sites, build once in CI/CD using `--build`; production machines only need Python and the built static files. Avoid building on every production restart.
109
+
110
+ **Production deployment:** Put an HTTPS reverse proxy or managed load balancer in front, use process supervision, secure the API using ordinary FastAPI dependencies/middleware, and add shared storage/caching as needed. QPyServe does not provide authentication, rate limiting, distributed state, or production infrastructure on its own.
111
+
112
+ ## Advanced options (optional)
113
+
114
+ ```python
115
+ run(
116
+ app,
117
+ frontend='frontend',
118
+ port=1111,
119
+ host='127.0.0.1',
120
+ workers=1,
121
+ api_paths=['/ws', '/custom'], # /api and FastAPI docs are always included
122
+ dist='dist',
123
+ app_import='main:app', # only needed for nonstandard entrypoints
124
+ )
125
+ ```
126
+
127
+ `api_paths` explicitly adds proxy prefixes for custom HTTP routes or WebSockets during Vite development and prevents those prefixes from falling back to the SPA HTML in production. It does not create or authenticate endpoints. Don't use a broad prefix such as `/` because it would consume frontend routes.
128
+
129
+ Bind `host='0.0.0.0'` only when you intentionally want to expose the service over a network; never expose the Vite development server to the public internet. `workers > 1` requires production mode and a shared state store for data that must persist across processes. The normal FastAPI OpenAPI/docs and external API clients keep working. Custom Vite proxy/SSR settings remain your frontend's responsibility when your app needs unusual behavior.
130
+
131
+ ## Scope and boundaries
132
+
133
+ - **QPyServe:** startup/orchestration, dependency detection, Vite development proxy, SPA/static file serving, basic dev reload.
134
+ - **FastAPI:** APIs, authentication, security, WebSockets, validation, database code, dependency injection.
135
+ - **Vite:** React/Vue/JS build, HMR, frontend plugins.
136
+ - **Deployment platform:** HTTPS, worker replicas, monitoring, database, caches, queues, backups.
137
+
138
+ Vite builds are static SPAs; Next.js server-side rendering, distributed WebSocket fan-out, automatic Kubernetes provisioning, and shared-memory magic are not provided. The HTML file server excludes hidden files, package manager manifests, and Python sources; don't put secrets in a public frontend directory.
139
+
140
+ ## Contributing and verification
141
+
142
+ ```bash
143
+ python -m pip install -e '.[test]'
144
+ python -m pytest -q
145
+ python -m build
146
+ ```
147
+
148
+ Check [AGENTS.md](AGENTS.md) for AI-assisted contributions and [RELEASING.md](RELEASING.md) for PyPI publishing. MIT License.
@@ -0,0 +1,120 @@
1
+ # qpyserve
2
+
3
+ **FastAPI + any frontend. One Python command. One browser address.**
4
+
5
+ QPyServe is a thin bridge, not a new web framework. Write normal FastAPI endpoints and use plain HTML/JS, React, Vue, or another Vite frontend. QPyServe handles local startup, frontend dependencies, API proxying, hot reload, and static production serving.
6
+
7
+ > **Status:** Early alpha. Not yet load-tested or security-audited. Multiple workers do not guarantee performance for 1,000 concurrent users.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install qpyserve
13
+ ```
14
+
15
+ For a source checkout: `python -m pip install -e .` instead. If not yet released to PyPI, install from GitHub: `pip install git+https://github.com/austinwin/qpyserve.git` (requires repository access).
16
+
17
+ ## Hello world: two files
18
+
19
+ `main.py`:
20
+
21
+ ```python
22
+ from fastapi import FastAPI
23
+ from qpyserve import run
24
+
25
+ app = FastAPI()
26
+
27
+ @app.get('/api/hello')
28
+ def hello():
29
+ return {'message': 'Hello from Python'}
30
+
31
+ if __name__ == '__main__':
32
+ run(app, frontend='frontend', port=1111)
33
+ ```
34
+
35
+ `frontend/index.html`:
36
+
37
+ ```html
38
+ <!doctype html>
39
+ <html lang="en">
40
+ <body>
41
+ <button id="hello">Call Python</button><pre id="result"></pre>
42
+ <script>
43
+ document.querySelector('#hello').onclick = async () => {
44
+ const response = await fetch('/api/hello');
45
+ document.querySelector('#result').textContent =
46
+ JSON.stringify(await response.json());
47
+ };
48
+ </script>
49
+ </body>
50
+ </html>
51
+ ```
52
+
53
+ ```bash
54
+ python main.py
55
+ ```
56
+
57
+ Open <http://localhost:1111>. `/api/hello` serves the same origin and `/docs` shows FastAPI documentation. No Node.js, package.json, or additional commands are needed for plain HTML. Edit HTML/JS and the development page automatically refreshes.
58
+
59
+ ## React, Vue, or another Vite frontend
60
+
61
+ Point `frontend='frontend'` to an ordinary Vite project containing `package.json` with `vite` in its dependencies. **The Python code does not change.** Run `python main.py`.
62
+
63
+ - QPyServe detects npm, pnpm, Yarn, or Bun from `packageManager` / lockfiles (npm is the fallback).
64
+ - It installs missing frontend dependencies with the selected package manager, then runs the locally installed Vite. No `npm run dev` script is required.
65
+ - Existing `vite.config.js` / `.ts` / `.mjs` and plugins are retained; Vite provides normal HMR.
66
+ - Development browser URL stays `http://localhost:1111`. The API runs on loopback `:1112` and is proxied through `:1111`.
67
+ - Node.js and the chosen package manager must be installed for Vite projects. QPyServe does **not** silently install Node or fetch arbitrary runtime binaries. It reports what is missing.
68
+
69
+ See the runnable [HTML counter](examples/counter-html) and [React counter](examples/counter-react).
70
+
71
+ ## Production and builds
72
+
73
+ | Command | Behavior |
74
+ | --- | --- |
75
+ | `python main.py` | Start dev server, HMR/reload; install frontend packages if needed |
76
+ | `python main.py --build` | Install packages if needed, build frontend, and exit |
77
+ | `python main.py --prod` | Serve prebuilt frontend and FastAPI on one port |
78
+ | `python main.py --prod --build` | Build frontend first, then serve it in production mode |
79
+
80
+ Plain HTML needs no build. For Vite sites, build once in CI/CD using `--build`; production machines only need Python and the built static files. Avoid building on every production restart.
81
+
82
+ **Production deployment:** Put an HTTPS reverse proxy or managed load balancer in front, use process supervision, secure the API using ordinary FastAPI dependencies/middleware, and add shared storage/caching as needed. QPyServe does not provide authentication, rate limiting, distributed state, or production infrastructure on its own.
83
+
84
+ ## Advanced options (optional)
85
+
86
+ ```python
87
+ run(
88
+ app,
89
+ frontend='frontend',
90
+ port=1111,
91
+ host='127.0.0.1',
92
+ workers=1,
93
+ api_paths=['/ws', '/custom'], # /api and FastAPI docs are always included
94
+ dist='dist',
95
+ app_import='main:app', # only needed for nonstandard entrypoints
96
+ )
97
+ ```
98
+
99
+ `api_paths` explicitly adds proxy prefixes for custom HTTP routes or WebSockets during Vite development and prevents those prefixes from falling back to the SPA HTML in production. It does not create or authenticate endpoints. Don't use a broad prefix such as `/` because it would consume frontend routes.
100
+
101
+ Bind `host='0.0.0.0'` only when you intentionally want to expose the service over a network; never expose the Vite development server to the public internet. `workers > 1` requires production mode and a shared state store for data that must persist across processes. The normal FastAPI OpenAPI/docs and external API clients keep working. Custom Vite proxy/SSR settings remain your frontend's responsibility when your app needs unusual behavior.
102
+
103
+ ## Scope and boundaries
104
+
105
+ - **QPyServe:** startup/orchestration, dependency detection, Vite development proxy, SPA/static file serving, basic dev reload.
106
+ - **FastAPI:** APIs, authentication, security, WebSockets, validation, database code, dependency injection.
107
+ - **Vite:** React/Vue/JS build, HMR, frontend plugins.
108
+ - **Deployment platform:** HTTPS, worker replicas, monitoring, database, caches, queues, backups.
109
+
110
+ Vite builds are static SPAs; Next.js server-side rendering, distributed WebSocket fan-out, automatic Kubernetes provisioning, and shared-memory magic are not provided. The HTML file server excludes hidden files, package manager manifests, and Python sources; don't put secrets in a public frontend directory.
111
+
112
+ ## Contributing and verification
113
+
114
+ ```bash
115
+ python -m pip install -e '.[test]'
116
+ python -m pytest -q
117
+ python -m build
118
+ ```
119
+
120
+ Check [AGENTS.md](AGENTS.md) for AI-assisted contributions and [RELEASING.md](RELEASING.md) for PyPI publishing. MIT License.
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "qpyserve"
7
+ version = "0.1.0"
8
+ description = "Run FastAPI with HTML, React, or Vue on one host using one Python command"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "qpyserve contributors" }]
13
+ keywords = ["fastapi", "vite", "react", "vue", "web", "asgi"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Framework :: FastAPI",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
23
+ ]
24
+ dependencies = ["fastapi>=0.110,<1", "uvicorn[standard]>=0.30,<1"]
25
+
26
+ [project.optional-dependencies]
27
+ test = ["pytest>=8", "httpx>=0.27", "build>=1.2"]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/austinwin/qpyserve"
31
+ Issues = "https://github.com/austinwin/qpyserve/issues"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """qpyserve: a tiny bridge between FastAPI and frontend applications."""
2
+
3
+ from ._runtime import run
4
+
5
+ __all__ = ["run"]
6
+ __version__ = "0.1.0"
@@ -0,0 +1,35 @@
1
+ """Importable Uvicorn factory so reload and multi-worker modes work."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import json
7
+ import os
8
+
9
+ from fastapi import FastAPI
10
+
11
+ from ._frontend import attach_frontend
12
+
13
+
14
+ def create_app() -> FastAPI:
15
+ raw = os.environ.get("QPYSERVE_RUNTIME_CONFIG")
16
+ if not raw:
17
+ raise RuntimeError("qpyserve must be started through run(app, frontend=...).")
18
+ config = json.loads(raw)
19
+ module_name, separator, attribute = config["app_import"].partition(":")
20
+ if not separator:
21
+ raise ValueError("app_import must be in 'module:variable' format")
22
+ module = importlib.import_module(module_name)
23
+ app = module
24
+ for part in attribute.split("."):
25
+ app = getattr(app, part)
26
+ if not isinstance(app, FastAPI):
27
+ raise TypeError(f"{config['app_import']} does not resolve to a FastAPI app")
28
+ if config["serve_frontend"]:
29
+ attach_frontend(app, config["static_directory"], live_reload=config["live_reload"],
30
+ api_paths=config.get("api_paths"))
31
+ else:
32
+ @app.get("/__qpyserve/health", include_in_schema=False)
33
+ def qpyserve_health() -> dict[str, str]:
34
+ return {"status": "ok"}
35
+ return app
@@ -0,0 +1,124 @@
1
+ """Serve an HTML/JS site or a built SPA through an existing FastAPI app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ import time
7
+ from threading import Lock
8
+ from typing import Iterable
9
+
10
+ from ._tooling import normalize_api_paths
11
+
12
+ from fastapi import FastAPI, HTTPException, Request
13
+ from fastapi.responses import FileResponse, HTMLResponse
14
+
15
+ _RELOAD_SCRIPT = """<script>
16
+ (() => {
17
+ let known;
18
+ async function check() {
19
+ try {
20
+ const response = await fetch('/__qpyserve/version', {cache: 'no-store'});
21
+ if (!response.ok) return;
22
+ const current = (await response.json()).version;
23
+ if (known !== undefined && known !== current) location.reload();
24
+ known = current;
25
+ } catch (_) { /* Server may be restarting. */ }
26
+ }
27
+ check();
28
+ setInterval(check, 1200);
29
+ })();
30
+ </script>"""
31
+
32
+
33
+ def _version(folder: Path) -> int:
34
+ """Latest edit time; used only for small local static sites in dev."""
35
+ latest = 0
36
+ for file in folder.rglob("*"):
37
+ if any(part in {"node_modules", ".git", "dist", "__pycache__"} for part in file.parts):
38
+ continue
39
+ if file.is_file():
40
+ try:
41
+ latest = max(latest, file.stat().st_mtime_ns)
42
+ except OSError:
43
+ pass
44
+ return latest
45
+
46
+
47
+ def attach_frontend(app: FastAPI, directory: str | Path, *, live_reload: bool = False,
48
+ api_paths: Iterable[str] | None = None) -> FastAPI:
49
+ """Attach last, after the caller has registered all API routes.
50
+
51
+ Unknown /api paths never return the SPA index. Non-existent assets return 404.
52
+ """
53
+ reserved_paths = normalize_api_paths(api_paths)
54
+ root = Path(directory).resolve()
55
+ if not (root / "index.html").is_file():
56
+ raise FileNotFoundError(f"No index.html found in {root}")
57
+
58
+ @app.get("/__qpyserve/health", include_in_schema=False)
59
+ def qpyserve_health() -> dict[str, str]:
60
+ return {"status": "ok"}
61
+
62
+ last_scan = [0.0, 0]
63
+ scan_lock = Lock()
64
+
65
+ if live_reload:
66
+ @app.get("/__qpyserve/version", include_in_schema=False)
67
+ def qpyserve_version() -> dict[str, int]:
68
+ # Multiple open tabs share one scan per second, not one scan per request.
69
+ with scan_lock:
70
+ now = time.monotonic()
71
+ if now - last_scan[0] >= 0.75:
72
+ last_scan[:] = [now, _version(root)]
73
+ return {"version": last_scan[1]}
74
+
75
+ # Keep missing API paths distinct from static frontend routes, for all methods.
76
+ @app.api_route(
77
+ "/api/{unmatched_path:path}",
78
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
79
+ include_in_schema=False,
80
+ )
81
+ async def unknown_api(unmatched_path: str):
82
+ raise HTTPException(404, "API endpoint not found")
83
+
84
+ @app.get("/{asset_path:path}", include_in_schema=False)
85
+ async def frontend_asset(asset_path: str, request: Request):
86
+ requested = "/" + asset_path
87
+ if any(requested == prefix or requested.startswith(prefix + "/")
88
+ for prefix in reserved_paths):
89
+ raise HTTPException(404, "Not found")
90
+
91
+ parts = Path(asset_path).parts
92
+ # A plain HTML frontend is a directory, not an invitation to publish its
93
+ # .env, package manager files, Python sources, or development config.
94
+ if any(part.startswith(".") for part in parts) or any(
95
+ part in {"node_modules", "__pycache__", "package.json", "package-lock.json",
96
+ "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"}
97
+ or part.startswith("vite.config.") or part.endswith((".py", ".pyc"))
98
+ for part in parts
99
+ ):
100
+ raise HTTPException(404, "Not found")
101
+
102
+ candidate = (root / asset_path).resolve()
103
+ if not candidate.is_relative_to(root):
104
+ raise HTTPException(404, "Not found")
105
+ if candidate.is_dir():
106
+ candidate = candidate / "index.html"
107
+ if not candidate.is_file():
108
+ # Single-page app history fallback, but not for missing JS/CSS/images.
109
+ if "." not in Path(asset_path).name and "text/html" in request.headers.get("accept", ""):
110
+ candidate = root / "index.html"
111
+ else:
112
+ raise HTTPException(404, "Not found")
113
+
114
+ # A stale SPA entrypoint often causes the "hard refresh required" symptom.
115
+ headers = ({"Cache-Control": "no-store"} if live_reload else
116
+ {"Cache-Control": "no-cache"} if candidate.suffix.lower() == ".html" else {})
117
+ if live_reload and candidate.suffix.lower() == ".html":
118
+ html = candidate.read_text(encoding="utf-8")
119
+ script = _RELOAD_SCRIPT
120
+ html = html.replace("</body>", f"{script}</body>") if "</body>" in html else html + script
121
+ return HTMLResponse(html, headers=headers)
122
+ return FileResponse(candidate, headers=headers)
123
+
124
+ return app
@@ -0,0 +1,160 @@
1
+ """Run a real FastAPI application with any HTML or Vite-based frontend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from pathlib import Path
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ from typing import Iterable, Literal
12
+
13
+ from fastapi import FastAPI
14
+ import uvicorn
15
+
16
+ from ._tooling import (
17
+ build_frontend, ensure_dependencies, has_vite, normalize_api_paths,
18
+ package_manager, vite_command, vite_config,
19
+ )
20
+
21
+ Mode = Literal["development", "production"]
22
+
23
+ # Retain private aliases for older users/tests; these are not public API.
24
+ _has_vite = has_vite
25
+ _vite_config = vite_config
26
+
27
+
28
+ def _stop_process(process: subprocess.Popen) -> None:
29
+ if process.poll() is not None:
30
+ return
31
+ if os.name == "nt":
32
+ # npm/pnpm/yarn/bun may create child processes on Windows.
33
+ subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
34
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
35
+ else:
36
+ import contextlib
37
+ with contextlib.suppress(ProcessLookupError):
38
+ os.killpg(process.pid, signal.SIGTERM)
39
+ try:
40
+ process.wait(timeout=5)
41
+ except subprocess.TimeoutExpired:
42
+ process.kill()
43
+ process.wait()
44
+
45
+
46
+ def run(
47
+ app: FastAPI,
48
+ frontend: str | Path = "frontend",
49
+ port: int = 1111,
50
+ *,
51
+ host: str = "127.0.0.1",
52
+ mode: Mode | None = None,
53
+ app_import: str | None = None,
54
+ dist: str = "dist",
55
+ workers: int = 1,
56
+ api_paths: Iterable[str] | None = None,
57
+ build: bool | None = None,
58
+ ) -> None:
59
+ """One-command runner. `--build` builds and exits; `--prod --build` builds then serves.
60
+
61
+ Plain HTML requires Python only. React/Vue require a Vite project and its selected
62
+ JS package manager, installed automatically when frontend packages are missing.
63
+ """
64
+ if not isinstance(app, FastAPI):
65
+ raise TypeError("run() requires a FastAPI application")
66
+ if not 1 <= port <= 65535:
67
+ raise ValueError("port must be 1..65535")
68
+ if workers < 1:
69
+ raise ValueError("workers must be >= 1")
70
+ if mode is None:
71
+ mode = "production" if "--prod" in sys.argv[1:] else os.environ.get("QPYSERVE_MODE", "development")
72
+ if mode not in ("development", "production"):
73
+ raise ValueError("mode must be 'development' or 'production'")
74
+ if mode == "development" and workers > 1:
75
+ raise ValueError("Multiple workers are supported only in production")
76
+ if build is None:
77
+ build = "--build" in sys.argv[1:]
78
+ paths = normalize_api_paths(api_paths)
79
+
80
+ script = Path(sys.argv[0]).resolve()
81
+ project_dir = script.parent
82
+ root = Path(frontend)
83
+ if not root.is_absolute():
84
+ root = project_dir / root
85
+ root = root.resolve()
86
+ if not root.is_dir():
87
+ raise FileNotFoundError(f"Frontend directory not found: {root}")
88
+ if not dist or Path(dist).is_absolute() or ".." in Path(dist).parts:
89
+ raise ValueError("dist must be a relative frontend build directory")
90
+
91
+ vite = has_vite(root)
92
+ static_directory = root / dist if vite else root
93
+ if build:
94
+ if vite:
95
+ build_frontend(root)
96
+ else:
97
+ print("[qpyserve] Plain HTML requires no build", flush=True)
98
+ if mode != "production":
99
+ return
100
+ if mode == "production" and not (static_directory / "index.html").is_file():
101
+ raise FileNotFoundError(
102
+ f"Built frontend missing: {static_directory / 'index.html'}. "
103
+ "Use 'python main.py --prod --build' to build and launch, "
104
+ "or build once in CI and run 'python main.py --prod'."
105
+ )
106
+ if not vite and not (root / "index.html").is_file():
107
+ raise FileNotFoundError(f"Plain HTML frontend must contain {root / 'index.html'}")
108
+ if vite and mode == "development" and port == 65535:
109
+ raise ValueError("Vite dev requires port and port+1; choose port <= 65534")
110
+
111
+ if app_import is None:
112
+ main_globals = vars(sys.modules["__main__"])
113
+ app_var = next((k for k, v in main_globals.items() if v is app and k.isidentifier()), None)
114
+ if not app_var or not script.stem.isidentifier():
115
+ raise ValueError("Cannot locate FastAPI app. Pass app_import='main:app' explicitly.")
116
+ app_import = f"{script.stem}:{app_var}"
117
+
118
+ config = {
119
+ "app_import": app_import,
120
+ "serve_frontend": not (vite and mode == "development"),
121
+ "static_directory": str(static_directory),
122
+ "live_reload": not vite and mode == "development",
123
+ "api_paths": paths,
124
+ }
125
+ previous = os.environ.get("QPYSERVE_RUNTIME_CONFIG")
126
+ os.environ["QPYSERVE_RUNTIME_CONFIG"] = json.dumps(config)
127
+ vite_process: subprocess.Popen | None = None
128
+ generated_config: Path | None = None
129
+ try:
130
+ if vite and mode == "development":
131
+ manager = package_manager(root)
132
+ command = ensure_dependencies(root, manager)
133
+ generated_config = vite_config(root, host=host, port=port,
134
+ backend_port=port + 1, api_paths=paths)
135
+ cmd = vite_command(command, manager, "--config", generated_config.name)
136
+ kwargs = ({"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
137
+ if os.name == "nt" else {"start_new_session": True})
138
+ vite_process = subprocess.Popen(cmd, cwd=root, **kwargs)
139
+ print(f"[qpyserve] http://localhost:{port} (Vite) /api -> localhost:{port + 1}", flush=True)
140
+ uvicorn.run("qpyserve._entry:create_app", factory=True, host="127.0.0.1",
141
+ port=port + 1, reload=True, reload_dirs=[str(project_dir)],
142
+ reload_includes=["*.py"],
143
+ reload_excludes=["node_modules", "dist", ".qpyserve.vite.config.mjs"])
144
+ else:
145
+ print(f"[qpyserve] http://localhost:{port} ({mode})", flush=True)
146
+ uvicorn.run("qpyserve._entry:create_app", factory=True, host=host,
147
+ port=port, reload=(mode == "development"),
148
+ workers=workers if mode == "production" else None,
149
+ reload_dirs=[str(project_dir)] if mode == "development" else None,
150
+ reload_includes=["*.py"] if mode == "development" else None,
151
+ reload_excludes=["node_modules", "dist"] if mode == "development" else None)
152
+ finally:
153
+ if vite_process is not None:
154
+ _stop_process(vite_process)
155
+ if generated_config is not None:
156
+ generated_config.unlink(missing_ok=True)
157
+ if previous is None:
158
+ os.environ.pop("QPYSERVE_RUNTIME_CONFIG", None)
159
+ else:
160
+ os.environ["QPYSERVE_RUNTIME_CONFIG"] = previous
@@ -0,0 +1,144 @@
1
+ """Optional Vite tooling; no JavaScript dependency is needed for plain HTML sites."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ import re
8
+ import shutil
9
+ import subprocess
10
+ from typing import Iterable
11
+
12
+
13
+ DEFAULT_API_PATHS = ("/api", "/docs", "/redoc", "/openapi.json", "/__qpyserve")
14
+
15
+
16
+ def normalize_api_paths(paths: Iterable[str] | None = None) -> tuple[str, ...]:
17
+ """Explicit API prefixes routed to Python in Vite dev and excluded from SPA fallback."""
18
+ if isinstance(paths, str):
19
+ paths = (paths,)
20
+ ordered = dict.fromkeys(DEFAULT_API_PATHS)
21
+ for path in paths or ():
22
+ if not isinstance(path, str) or not path.startswith("/") or path.startswith("//"):
23
+ raise ValueError(f"API path must start with one '/': {path!r}")
24
+ if path == "/" or any(ch in path for ch in ("?", "#", "\\", "*", "\n", "\r")):
25
+ raise ValueError(f"Invalid API proxy prefix: {path!r}")
26
+ path = path.rstrip("/")
27
+ if not path or "//" in path or "/./" in path or "/../" in path or path.endswith(("/.", "/..")):
28
+ raise ValueError(f"Invalid API proxy prefix: {path!r}")
29
+ ordered[path] = None
30
+ return tuple(ordered)
31
+
32
+
33
+ def has_vite(folder: Path) -> bool:
34
+ manifest = folder / "package.json"
35
+ if not manifest.is_file():
36
+ return False
37
+ try:
38
+ data = json.loads(manifest.read_text(encoding="utf-8"))
39
+ except json.JSONDecodeError as exc:
40
+ raise ValueError(f"Invalid frontend/package.json: {exc}") from exc
41
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
42
+ return "vite" in deps
43
+
44
+
45
+ def package_manager(folder: Path) -> str:
46
+ package_json = json.loads((folder / "package.json").read_text(encoding="utf-8"))
47
+ specified = package_json.get("packageManager", "").split("@", 1)[0]
48
+ if specified:
49
+ if specified not in ("npm", "pnpm", "yarn", "bun"):
50
+ raise ValueError(f"Unsupported package manager: {specified}")
51
+ return specified
52
+ for filename, manager in (
53
+ ("pnpm-lock.yaml", "pnpm"), ("yarn.lock", "yarn"),
54
+ ("bun.lock", "bun"), ("bun.lockb", "bun"),
55
+ ("package-lock.json", "npm"), ("npm-shrinkwrap.json", "npm"),
56
+ ):
57
+ if (folder / filename).is_file():
58
+ return manager
59
+ return "npm"
60
+
61
+
62
+ def _manager_command(manager: str) -> list[str]:
63
+ executable = shutil.which(manager)
64
+ if executable:
65
+ return [executable]
66
+ if manager in ("pnpm", "yarn"):
67
+ corepack = shutil.which("corepack")
68
+ if corepack:
69
+ return [corepack, manager]
70
+ raise RuntimeError(
71
+ f"Frontend requires {manager!r}, but it is not installed. "
72
+ "Install Node.js and the selected package manager (or Corepack). "
73
+ "Plain HTML frontends need no JavaScript tooling."
74
+ )
75
+
76
+
77
+ def ensure_dependencies(folder: Path, manager: str) -> list[str]:
78
+ command = _manager_command(manager)
79
+ vite_bin = folder / "node_modules" / "vite"
80
+ pnp = folder / ".pnp.cjs"
81
+ if vite_bin.exists() or (manager == "yarn" and pnp.is_file()):
82
+ return command
83
+ install_args = {"npm": ["install"], "pnpm": ["install"],
84
+ "yarn": ["install"], "bun": ["install"]}[manager]
85
+ if manager == "npm" and any((folder / n).is_file() for n in ("package-lock.json", "npm-shrinkwrap.json")):
86
+ install_args = ["ci"]
87
+ if manager == "pnpm" and (folder / "pnpm-lock.yaml").is_file():
88
+ install_args = ["install", "--frozen-lockfile"]
89
+ print(f"[qpyserve] Installing frontend dependencies ({manager})", flush=True)
90
+ subprocess.run([*command, *install_args], cwd=folder, check=True)
91
+ return command
92
+
93
+
94
+ def vite_command(manager_cmd: list[str], manager: str, *args: str) -> list[str]:
95
+ # Invoke local Vite directly: package.json does not need a `dev` or `build` script.
96
+ if manager == "npm":
97
+ return [*manager_cmd, "exec", "--no", "--", "vite", *args]
98
+ if manager == "pnpm":
99
+ return [*manager_cmd, "exec", "vite", *args]
100
+ if manager == "yarn":
101
+ return [*manager_cmd, "vite", *args]
102
+ return [*manager_cmd, "x", "--no-install", "vite", *args]
103
+
104
+
105
+ def build_frontend(folder: Path) -> None:
106
+ manager = package_manager(folder)
107
+ command = ensure_dependencies(folder, manager)
108
+ manifest = json.loads((folder / "package.json").read_text(encoding="utf-8"))
109
+ if "build" in manifest.get("scripts", {}):
110
+ args = ["run", "build"] if manager in ("npm", "pnpm", "bun") else ["build"]
111
+ cmd = [*command, *args]
112
+ else:
113
+ cmd = vite_command(command, manager, "build")
114
+ print("[qpyserve] Building frontend", flush=True)
115
+ subprocess.run(cmd, cwd=folder, check=True)
116
+
117
+
118
+ def vite_config(folder: Path, *, host: str, port: int, backend_port: int,
119
+ api_paths: Iterable[str] = DEFAULT_API_PATHS) -> Path:
120
+ config = folder / ".qpyserve.vite.config.mjs"
121
+ existing = next((p for p in (
122
+ "vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs",
123
+ "vite.config.cts", "vite.config.cjs"
124
+ ) if (folder / p).is_file()), None)
125
+ original = f"import userConfig from './{existing}';\n" if existing else "const userConfig = {};\n"
126
+ proxies = {
127
+ f"^{re.escape(path)}(?:/|$)": {
128
+ "target": f"http://127.0.0.1:{backend_port}",
129
+ "changeOrigin": True,
130
+ "ws": True,
131
+ }
132
+ for path in api_paths
133
+ }
134
+ server = {"host": host, "port": port, "strictPort": True, "proxy": proxies}
135
+ config.write_text(
136
+ "import { defineConfig, mergeConfig } from 'vite';\n"
137
+ + original
138
+ + "export default defineConfig(async (env) => {\n"
139
+ + " const base = typeof userConfig === 'function' ? await userConfig(env) : userConfig;\n"
140
+ + f" return mergeConfig(base || {{}}, {{ server: {json.dumps(server)} }});\n"
141
+ + "});\n",
142
+ encoding="utf-8",
143
+ )
144
+ return config
File without changes
@@ -0,0 +1,148 @@
1
+ Metadata-Version: 2.4
2
+ Name: qpyserve
3
+ Version: 0.1.0
4
+ Summary: Run FastAPI with HTML, React, or Vue on one host using one Python command
5
+ Author: qpyserve contributors
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/austinwin/qpyserve
8
+ Project-URL: Issues, https://github.com/austinwin/qpyserve/issues
9
+ Keywords: fastapi,vite,react,vue,web,asgi
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: FastAPI
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: fastapi<1,>=0.110
22
+ Requires-Dist: uvicorn[standard]<1,>=0.30
23
+ Provides-Extra: test
24
+ Requires-Dist: pytest>=8; extra == "test"
25
+ Requires-Dist: httpx>=0.27; extra == "test"
26
+ Requires-Dist: build>=1.2; extra == "test"
27
+ Dynamic: license-file
28
+
29
+ # qpyserve
30
+
31
+ **FastAPI + any frontend. One Python command. One browser address.**
32
+
33
+ QPyServe is a thin bridge, not a new web framework. Write normal FastAPI endpoints and use plain HTML/JS, React, Vue, or another Vite frontend. QPyServe handles local startup, frontend dependencies, API proxying, hot reload, and static production serving.
34
+
35
+ > **Status:** Early alpha. Not yet load-tested or security-audited. Multiple workers do not guarantee performance for 1,000 concurrent users.
36
+
37
+ ## Installation
38
+
39
+ ```bash
40
+ pip install qpyserve
41
+ ```
42
+
43
+ For a source checkout: `python -m pip install -e .` instead. If not yet released to PyPI, install from GitHub: `pip install git+https://github.com/austinwin/qpyserve.git` (requires repository access).
44
+
45
+ ## Hello world: two files
46
+
47
+ `main.py`:
48
+
49
+ ```python
50
+ from fastapi import FastAPI
51
+ from qpyserve import run
52
+
53
+ app = FastAPI()
54
+
55
+ @app.get('/api/hello')
56
+ def hello():
57
+ return {'message': 'Hello from Python'}
58
+
59
+ if __name__ == '__main__':
60
+ run(app, frontend='frontend', port=1111)
61
+ ```
62
+
63
+ `frontend/index.html`:
64
+
65
+ ```html
66
+ <!doctype html>
67
+ <html lang="en">
68
+ <body>
69
+ <button id="hello">Call Python</button><pre id="result"></pre>
70
+ <script>
71
+ document.querySelector('#hello').onclick = async () => {
72
+ const response = await fetch('/api/hello');
73
+ document.querySelector('#result').textContent =
74
+ JSON.stringify(await response.json());
75
+ };
76
+ </script>
77
+ </body>
78
+ </html>
79
+ ```
80
+
81
+ ```bash
82
+ python main.py
83
+ ```
84
+
85
+ Open <http://localhost:1111>. `/api/hello` serves the same origin and `/docs` shows FastAPI documentation. No Node.js, package.json, or additional commands are needed for plain HTML. Edit HTML/JS and the development page automatically refreshes.
86
+
87
+ ## React, Vue, or another Vite frontend
88
+
89
+ Point `frontend='frontend'` to an ordinary Vite project containing `package.json` with `vite` in its dependencies. **The Python code does not change.** Run `python main.py`.
90
+
91
+ - QPyServe detects npm, pnpm, Yarn, or Bun from `packageManager` / lockfiles (npm is the fallback).
92
+ - It installs missing frontend dependencies with the selected package manager, then runs the locally installed Vite. No `npm run dev` script is required.
93
+ - Existing `vite.config.js` / `.ts` / `.mjs` and plugins are retained; Vite provides normal HMR.
94
+ - Development browser URL stays `http://localhost:1111`. The API runs on loopback `:1112` and is proxied through `:1111`.
95
+ - Node.js and the chosen package manager must be installed for Vite projects. QPyServe does **not** silently install Node or fetch arbitrary runtime binaries. It reports what is missing.
96
+
97
+ See the runnable [HTML counter](examples/counter-html) and [React counter](examples/counter-react).
98
+
99
+ ## Production and builds
100
+
101
+ | Command | Behavior |
102
+ | --- | --- |
103
+ | `python main.py` | Start dev server, HMR/reload; install frontend packages if needed |
104
+ | `python main.py --build` | Install packages if needed, build frontend, and exit |
105
+ | `python main.py --prod` | Serve prebuilt frontend and FastAPI on one port |
106
+ | `python main.py --prod --build` | Build frontend first, then serve it in production mode |
107
+
108
+ Plain HTML needs no build. For Vite sites, build once in CI/CD using `--build`; production machines only need Python and the built static files. Avoid building on every production restart.
109
+
110
+ **Production deployment:** Put an HTTPS reverse proxy or managed load balancer in front, use process supervision, secure the API using ordinary FastAPI dependencies/middleware, and add shared storage/caching as needed. QPyServe does not provide authentication, rate limiting, distributed state, or production infrastructure on its own.
111
+
112
+ ## Advanced options (optional)
113
+
114
+ ```python
115
+ run(
116
+ app,
117
+ frontend='frontend',
118
+ port=1111,
119
+ host='127.0.0.1',
120
+ workers=1,
121
+ api_paths=['/ws', '/custom'], # /api and FastAPI docs are always included
122
+ dist='dist',
123
+ app_import='main:app', # only needed for nonstandard entrypoints
124
+ )
125
+ ```
126
+
127
+ `api_paths` explicitly adds proxy prefixes for custom HTTP routes or WebSockets during Vite development and prevents those prefixes from falling back to the SPA HTML in production. It does not create or authenticate endpoints. Don't use a broad prefix such as `/` because it would consume frontend routes.
128
+
129
+ Bind `host='0.0.0.0'` only when you intentionally want to expose the service over a network; never expose the Vite development server to the public internet. `workers > 1` requires production mode and a shared state store for data that must persist across processes. The normal FastAPI OpenAPI/docs and external API clients keep working. Custom Vite proxy/SSR settings remain your frontend's responsibility when your app needs unusual behavior.
130
+
131
+ ## Scope and boundaries
132
+
133
+ - **QPyServe:** startup/orchestration, dependency detection, Vite development proxy, SPA/static file serving, basic dev reload.
134
+ - **FastAPI:** APIs, authentication, security, WebSockets, validation, database code, dependency injection.
135
+ - **Vite:** React/Vue/JS build, HMR, frontend plugins.
136
+ - **Deployment platform:** HTTPS, worker replicas, monitoring, database, caches, queues, backups.
137
+
138
+ Vite builds are static SPAs; Next.js server-side rendering, distributed WebSocket fan-out, automatic Kubernetes provisioning, and shared-memory magic are not provided. The HTML file server excludes hidden files, package manager manifests, and Python sources; don't put secrets in a public frontend directory.
139
+
140
+ ## Contributing and verification
141
+
142
+ ```bash
143
+ python -m pip install -e '.[test]'
144
+ python -m pytest -q
145
+ python -m build
146
+ ```
147
+
148
+ Check [AGENTS.md](AGENTS.md) for AI-assisted contributions and [RELEASING.md](RELEASING.md) for PyPI publishing. MIT License.
@@ -0,0 +1,17 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/qpyserve/__init__.py
5
+ src/qpyserve/_entry.py
6
+ src/qpyserve/_frontend.py
7
+ src/qpyserve/_runtime.py
8
+ src/qpyserve/_tooling.py
9
+ src/qpyserve/py.typed
10
+ src/qpyserve.egg-info/PKG-INFO
11
+ src/qpyserve.egg-info/SOURCES.txt
12
+ src/qpyserve.egg-info/dependency_links.txt
13
+ src/qpyserve.egg-info/requires.txt
14
+ src/qpyserve.egg-info/top_level.txt
15
+ tests/test_frontend.py
16
+ tests/test_runtime.py
17
+ tests/test_tooling.py
@@ -0,0 +1,7 @@
1
+ fastapi<1,>=0.110
2
+ uvicorn[standard]<1,>=0.30
3
+
4
+ [test]
5
+ pytest>=8
6
+ httpx>=0.27
7
+ build>=1.2
@@ -0,0 +1 @@
1
+ qpyserve
@@ -0,0 +1,104 @@
1
+ from pathlib import Path
2
+
3
+ from fastapi import FastAPI
4
+ from fastapi.testclient import TestClient
5
+ import pytest
6
+
7
+ from qpyserve._frontend import attach_frontend
8
+ from qpyserve._runtime import _has_vite, _vite_config
9
+
10
+
11
+ def client_with_site(tmp_path: Path, live_reload=False):
12
+ (tmp_path / "index.html").write_text("<html><body>Hello</body></html>")
13
+ (tmp_path / "app.js").write_text("console.log('ok')")
14
+ app = FastAPI()
15
+
16
+ @app.get("/api/ping")
17
+ def ping():
18
+ return {"ok": True}
19
+
20
+ attach_frontend(app, tmp_path, live_reload=live_reload)
21
+ return TestClient(app)
22
+
23
+
24
+ def test_api_and_html_share_origin(tmp_path):
25
+ client = client_with_site(tmp_path)
26
+ assert client.get("/api/ping").json() == {"ok": True}
27
+ assert "Hello" in client.get("/").text
28
+ assert "console.log" in client.get("/app.js").text
29
+ assert client.get("/__qpyserve/health").json() == {"status": "ok"}
30
+
31
+
32
+ def test_spa_history_fallback_and_missing_assets(tmp_path):
33
+ client = client_with_site(tmp_path)
34
+ assert "Hello" in client.get("/facilities/123", headers={"accept": "text/html"}).text
35
+ assert client.get("/missing.js").status_code == 404
36
+ assert client.get("/api/missing", headers={"accept": "text/html"}).status_code == 404
37
+ assert client.post("/api/missing").status_code == 404
38
+ assert client.get("/apiary", headers={"accept": "application/json"}).status_code == 404
39
+ assert client.get("/../other.txt").status_code == 404
40
+
41
+
42
+ def test_html_live_reload_is_dev_only(tmp_path):
43
+ dev = client_with_site(tmp_path, live_reload=True)
44
+ assert "__qpyserve/version" in dev.get("/").text
45
+ version = dev.get("/__qpyserve/version")
46
+ assert version.status_code == 200 and version.json()["version"] > 0
47
+ prod = client_with_site(tmp_path)
48
+ assert "__qpyserve/version" not in prod.get("/").text
49
+ assert prod.get("/__qpyserve/version").status_code == 404
50
+
51
+
52
+ def test_vite_detection_and_config(tmp_path):
53
+ (tmp_path / "package.json").write_text('{"devDependencies":{"vite":"^6.0.0"}}')
54
+ (tmp_path / "vite.config.ts").write_text("export default {}")
55
+ assert _has_vite(tmp_path)
56
+ generated = _vite_config(tmp_path, host="127.0.0.1", port=1111, backend_port=1112)
57
+ text = generated.read_text()
58
+ assert "vite.config.ts" in text
59
+ assert "1112" in text
60
+ assert "strictPort" in text
61
+
62
+
63
+ def test_missing_index(tmp_path):
64
+ with pytest.raises(FileNotFoundError):
65
+ attach_frontend(FastAPI(), tmp_path)
66
+
67
+
68
+ def test_custom_api_path_never_falls_back_to_html(tmp_path):
69
+ (tmp_path / "index.html").write_text("<html>App</html>")
70
+ app = FastAPI()
71
+
72
+ @app.get("/ws/status")
73
+ def status():
74
+ return {"status": "ready"}
75
+
76
+ attach_frontend(app, tmp_path, api_paths=["/ws", "/reports"])
77
+ client = TestClient(app)
78
+ assert client.get("/ws/status").json() == {"status": "ready"}
79
+ assert client.get("/ws/missing", headers={"accept": "text/html"}).status_code == 404
80
+ assert client.get("/reports/missing", headers={"accept": "text/html"}).status_code == 404
81
+ assert "App" in client.get("/my-page", headers={"accept": "text/html"}).text
82
+
83
+
84
+ def test_version_scan_is_cached_for_multiple_clients(tmp_path, monkeypatch):
85
+ (tmp_path / "index.html").write_text("<html>Test</html>")
86
+ app = FastAPI()
87
+ from qpyserve import _frontend
88
+ scans = []
89
+ monkeypatch.setattr(_frontend, "_version", lambda _: scans.append(1) or 42)
90
+ attach_frontend(app, tmp_path, live_reload=True)
91
+ client = TestClient(app)
92
+ assert client.get("/__qpyserve/version").json()["version"] == 42
93
+ assert client.get("/__qpyserve/version").json()["version"] == 42
94
+ assert scans == [1]
95
+
96
+
97
+ def test_hidden_and_project_source_files_not_public(tmp_path):
98
+ client = client_with_site(tmp_path)
99
+ for name in (".env", "package.json", "vite.config.js", "app.py", "node_modules/secret.js"):
100
+ candidate = tmp_path / name
101
+ candidate.parent.mkdir(parents=True, exist_ok=True)
102
+ candidate.write_text("SECRET")
103
+ assert client.get("/" + name).status_code == 404
104
+ assert client.get("/").headers["cache-control"] == "no-cache"
@@ -0,0 +1,49 @@
1
+ import sys
2
+
3
+ from fastapi import FastAPI
4
+ import pytest
5
+
6
+ from qpyserve import run
7
+
8
+
9
+ def test_run_validates_app(tmp_path):
10
+ with pytest.raises(TypeError):
11
+ run(object(), frontend=tmp_path)
12
+
13
+
14
+ def test_run_rejects_bad_port(tmp_path):
15
+ with pytest.raises(ValueError, match="port"):
16
+ run(FastAPI(), frontend=tmp_path, port=0)
17
+
18
+
19
+ def test_run_rejects_multiple_dev_workers(tmp_path):
20
+ with pytest.raises(ValueError, match="workers"):
21
+ run(FastAPI(), frontend=tmp_path, workers=2, mode="development")
22
+
23
+
24
+ def test_run_rejects_missing_frontend(tmp_path):
25
+ with pytest.raises(FileNotFoundError, match="Frontend directory"):
26
+ run(FastAPI(), frontend=tmp_path / "missing", mode="production")
27
+
28
+
29
+ def test_build_only_does_not_start_server(tmp_path, monkeypatch):
30
+ import qpyserve._runtime as rt
31
+
32
+ frontend = tmp_path / "frontend"
33
+ frontend.mkdir()
34
+ (frontend / "package.json").write_text('{"devDependencies":{"vite":"^6"}}')
35
+ monkeypatch.setattr(sys, "argv", [str(tmp_path / "main.py"), "--build"])
36
+ calls = []
37
+ monkeypatch.setattr(rt, "build_frontend", lambda _: calls.append("build"))
38
+ monkeypatch.setattr(rt.uvicorn, "run", lambda *_a, **_kw: calls.append("serve"))
39
+ run(FastAPI(), frontend=frontend)
40
+ assert calls == ["build"]
41
+
42
+
43
+ def test_production_missing_build_explains_one_command_option(tmp_path, monkeypatch):
44
+ frontend = tmp_path / "frontend"
45
+ frontend.mkdir()
46
+ (frontend / "package.json").write_text('{"devDependencies":{"vite":"^6"}}')
47
+ monkeypatch.setattr(sys, "argv", [str(tmp_path / "main.py")])
48
+ with pytest.raises(FileNotFoundError, match="--prod --build"):
49
+ run(FastAPI(), frontend=frontend, mode="production")
@@ -0,0 +1,107 @@
1
+ """No network/npm installation required: subprocess calls are stubbed."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from unittest.mock import Mock
6
+
7
+ import pytest
8
+
9
+ from qpyserve._tooling import (
10
+ build_frontend, ensure_dependencies, has_vite, normalize_api_paths,
11
+ package_manager, vite_command, vite_config,
12
+ )
13
+
14
+
15
+ def make_vite(tmp_path: Path, *, scripts=None, manager=None):
16
+ package = {"devDependencies": {"vite": "^6"}, "scripts": scripts or {}}
17
+ if manager:
18
+ package["packageManager"] = manager
19
+ (tmp_path / "package.json").write_text(json.dumps(package))
20
+ return tmp_path
21
+
22
+
23
+ def test_detect_vite_without_dev_script(tmp_path):
24
+ make_vite(tmp_path)
25
+ assert has_vite(tmp_path)
26
+ assert package_manager(tmp_path) == "npm"
27
+
28
+
29
+ @pytest.mark.parametrize("lock,expected", [
30
+ ("package-lock.json", "npm"), ("pnpm-lock.yaml", "pnpm"),
31
+ ("yarn.lock", "yarn"), ("bun.lock", "bun"),
32
+ ])
33
+ def test_choose_lockfile_package_manager(tmp_path, lock, expected):
34
+ make_vite(tmp_path)
35
+ (tmp_path / lock).write_text("{}")
36
+ assert package_manager(tmp_path) == expected
37
+
38
+
39
+ def test_package_manager_field_precedes_lockfile(tmp_path):
40
+ make_vite(tmp_path, manager="pnpm@9.0.0")
41
+ (tmp_path / "package-lock.json").write_text("{}")
42
+ assert package_manager(tmp_path) == "pnpm"
43
+
44
+
45
+ @pytest.mark.parametrize("invalid", ["/", "api", "//ws", "/a/../b", "/api?x", "/a/*"])
46
+ def test_invalid_api_prefixes(invalid):
47
+ with pytest.raises(ValueError):
48
+ normalize_api_paths([invalid])
49
+
50
+
51
+ def test_api_paths_deduplicated_and_custom_websocket_proxy(tmp_path):
52
+ make_vite(tmp_path)
53
+ paths = normalize_api_paths(["/api", "/ws/", "/reports"])
54
+ assert paths.count("/api") == 1
55
+ config = vite_config(tmp_path, host="127.0.0.1", port=1111,
56
+ backend_port=1112, api_paths=paths).read_text()
57
+ assert "^/ws(?:/|$)" in config
58
+ assert "^/reports(?:/|$)" in config
59
+ assert '"ws": true' in config
60
+ assert "1112" in config
61
+
62
+
63
+ def test_package_install_is_automatic_and_uses_lockfile(tmp_path, monkeypatch):
64
+ make_vite(tmp_path)
65
+ (tmp_path / "package-lock.json").write_text("{}")
66
+ monkeypatch.setattr("qpyserve._tooling.shutil.which", lambda _: "/usr/bin/npm")
67
+ call = Mock()
68
+ monkeypatch.setattr("qpyserve._tooling.subprocess.run", call)
69
+ assert ensure_dependencies(tmp_path, "npm") == ["/usr/bin/npm"]
70
+ call.assert_called_once_with(["/usr/bin/npm", "ci"], cwd=tmp_path, check=True)
71
+
72
+
73
+ def test_vite_does_not_require_dev_script():
74
+ assert vite_command(["npm"], "npm", "--config", "generated.mjs") == [
75
+ "npm", "exec", "--no", "--", "vite", "--config", "generated.mjs"
76
+ ]
77
+
78
+
79
+ def test_build_falls_back_to_vite_without_build_script(tmp_path, monkeypatch):
80
+ make_vite(tmp_path)
81
+ monkeypatch.setattr("qpyserve._tooling.ensure_dependencies", lambda *_: ["npm"])
82
+ call = Mock()
83
+ monkeypatch.setattr("qpyserve._tooling.subprocess.run", call)
84
+ build_frontend(tmp_path)
85
+ call.assert_called_once_with(
86
+ ["npm", "exec", "--no", "--", "vite", "build"],
87
+ cwd=tmp_path, check=True,
88
+ )
89
+
90
+
91
+ def test_build_uses_custom_build_script(tmp_path, monkeypatch):
92
+ make_vite(tmp_path, scripts={"build": "my-custom-build"})
93
+ monkeypatch.setattr("qpyserve._tooling.ensure_dependencies", lambda *_: ["npm"])
94
+ call = Mock()
95
+ monkeypatch.setattr("qpyserve._tooling.subprocess.run", call)
96
+ build_frontend(tmp_path)
97
+ call.assert_called_once_with(["npm", "run", "build"], cwd=tmp_path, check=True)
98
+
99
+
100
+ def test_yarn_pnp_does_not_require_node_modules(tmp_path, monkeypatch):
101
+ make_vite(tmp_path)
102
+ (tmp_path / ".pnp.cjs").touch()
103
+ monkeypatch.setattr("qpyserve._tooling.shutil.which", lambda _: "/usr/bin/yarn")
104
+ call = Mock()
105
+ monkeypatch.setattr("qpyserve._tooling.subprocess.run", call)
106
+ assert ensure_dependencies(tmp_path, "yarn") == ["/usr/bin/yarn"]
107
+ call.assert_not_called()