Skip to content

Commit dbbf3c3

Browse files
committed
feat: Simple PyPI server with Starlette
Implements a simple PyPI server with Starlette and uvicorn. [Starlette](https://starlette.dev) is an ASGI framework with good performance. FileResponse supports async streaming with HTTP range requests. [Uvicorn](https://uvicorn.dev/) is an ASGI web server. Both combined are currently one of the fast, most-performaned ASGI frameworks for Python. Starlette and uvicorn add few additional dependencies: `sniffio`, `h11`, and `anyio`. The `SimpleHTMLIndex` class implements a very simple HTML repository API with room to add additional features like project metadata. Signed-off-by: Christian Heimes <[email protected]>
1 parent 64aee9d commit dbbf3c3

File tree

3 files changed

+175
-32
lines changed

3 files changed

+175
-32
lines changed

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@ dependencies = [
4444
"requests",
4545
"resolvelib",
4646
"requests-mock",
47+
"starlette",
4748
"stevedore",
4849
"tomlkit",
4950
"tqdm",
5051
"wheel",
5152
"uv>=0.8.19",
53+
"uvicorn",
5254
]
5355

5456
[project.optional-dependencies]

src/fromager/commands/server.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,10 @@ def wheel_server(
2727
) -> None:
2828
"Start a web server to serve the local wheels-repo"
2929
server.update_wheel_mirror(wkctx)
30-
t = server.run_wheel_server(
30+
_, _, thread = server.run_wheel_server(
3131
wkctx,
3232
address=address,
3333
port=port,
3434
)
3535
print(f"Listening on {wkctx.wheel_server_url}")
36-
t.join()
36+
thread.join()

src/fromager/server.py

Lines changed: 171 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
from __future__ import annotations
22

3-
import functools
4-
import http.server
3+
import asyncio
54
import logging
65
import os
76
import pathlib
87
import shutil
8+
import socket
9+
import stat
10+
import textwrap
911
import threading
1012
import typing
13+
from urllib.parse import quote
1114

15+
import uvicorn
1216
from packaging.utils import parse_wheel_filename
17+
from starlette.applications import Starlette
18+
from starlette.exceptions import HTTPException
19+
from starlette.requests import Request
20+
from starlette.responses import FileResponse, HTMLResponse, RedirectResponse, Response
21+
from starlette.routing import Route
1322

1423
from .threading_utils import with_thread_lock
1524

@@ -19,11 +28,6 @@
1928
logger = logging.getLogger(__name__)
2029

2130

22-
class LoggingHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
23-
def log_message(self, format: str, *args: typing.Any) -> None:
24-
logger.debug(format, *args)
25-
26-
2731
def start_wheel_server(ctx: context.WorkContext) -> None:
2832
update_wheel_mirror(ctx)
2933
if ctx.wheel_server_url:
@@ -34,33 +38,24 @@ def start_wheel_server(ctx: context.WorkContext) -> None:
3438

3539
def run_wheel_server(
3640
ctx: context.WorkContext,
37-
address: str = "localhost",
41+
address: str = "127.0.0.1",
3842
port: int = 0,
39-
) -> threading.Thread:
40-
server = http.server.ThreadingHTTPServer(
41-
(address, port),
42-
functools.partial(LoggingHTTPRequestHandler, directory=str(ctx.wheels_repo)),
43-
bind_and_activate=False,
43+
) -> tuple[uvicorn.Server, socket.socket, threading.Thread]:
44+
try:
45+
loop = asyncio.get_running_loop()
46+
except RuntimeError:
47+
loop = asyncio.new_event_loop()
48+
49+
app = make_app(ctx.wheel_server_dir)
50+
server, sock, thread = _run_background_thread(
51+
loop=loop, app=app, host=address, port=port
4452
)
45-
server.timeout = 0.5
46-
server.allow_reuse_address = True
47-
48-
logger.debug(f"address {server.server_address}")
49-
server.server_bind()
50-
ctx.wheel_server_url = f"http://{address}:{server.server_port}/simple/"
5153

52-
logger.debug("starting wheel server at %s", ctx.wheel_server_url)
53-
server.server_activate()
54+
realport = sock.getsockname()[1]
55+
ctx.wheel_server_url = f"http://{address}:{realport}/simple/"
5456

55-
def serve_forever(server: http.server.ThreadingHTTPServer) -> None:
56-
# ensure server.server_close() is called
57-
with server:
58-
server.serve_forever()
59-
60-
t = threading.Thread(target=serve_forever, args=(server,))
61-
t.setDaemon(True)
62-
t.start()
63-
return t
57+
logger.info("started wheel server at %s", ctx.wheel_server_url)
58+
return server, sock, thread
6459

6560

6661
@with_thread_lock()
@@ -92,3 +87,149 @@ def update_wheel_mirror(ctx: context.WorkContext) -> None:
9287
logger.debug("linking %s -> %s into local index", wheel.name, relpath)
9388
simple_dest_filename.parent.mkdir(parents=True, exist_ok=True)
9489
simple_dest_filename.symlink_to(relpath)
90+
91+
92+
class SimpleHTMLIndex:
93+
"""Simple HTML Repository API (1.0)
94+
95+
https://packaging.python.org/en/latest/specifications/simple-repository-api/
96+
"""
97+
98+
html_index = textwrap.dedent(
99+
"""\
100+
<!DOCTYPE html>
101+
<html lang="en">
102+
<head>
103+
<meta name="pypi:repository-version" content="1.0">
104+
<title>Simple index</title>
105+
</head>
106+
<body>
107+
{entries}
108+
</body>
109+
</html>
110+
"""
111+
)
112+
113+
html_project = textwrap.dedent(
114+
"""\
115+
<!DOCTYPE html>
116+
<html lang="en">
117+
<head>
118+
<meta name="pypi:repository-version" content="1.0">
119+
<title>Links for {project}</title>
120+
</head>
121+
<body>
122+
<h1>Links for {project}</h1>
123+
{entries}
124+
</body>
125+
</html>
126+
"""
127+
)
128+
129+
def __init__(self, basedir: pathlib.Path) -> None:
130+
self.basedir = basedir.resolve()
131+
132+
def _as_anchor(self, prefix: str, direntry: os.DirEntry) -> str:
133+
quoted = quote(direntry.name)
134+
return f'<a href="{prefix}/{quoted}">{quoted}</a><br/>'
135+
136+
async def root(self, request: Request) -> Response:
137+
return RedirectResponse(url="/simple")
138+
139+
async def index_page(self, request: Request) -> Response:
140+
prefix = "/simple"
141+
try:
142+
dirs = [
143+
self._as_anchor(prefix, direntry)
144+
for direntry in os.scandir(self.basedir)
145+
if direntry.is_dir(follow_symlinks=False)
146+
]
147+
except FileNotFoundError:
148+
raise HTTPException(
149+
status_code=404, detail=f"'{self.basedir}' missing"
150+
) from None
151+
152+
content = self.html_index.format(entries="\n".join(dirs))
153+
return HTMLResponse(content=content)
154+
155+
async def project_page(self, request: Request) -> Response:
156+
project = request.path_params["project"]
157+
project_dir = self.basedir / project
158+
prefix = f"/simple/{project}"
159+
try:
160+
dirs = [
161+
self._as_anchor(prefix, direntry)
162+
for direntry in os.scandir(project_dir)
163+
if direntry.name.endswith((".whl", ".whl.metadata", ".tar.gz"))
164+
and direntry.is_file(follow_symlinks=True)
165+
]
166+
except FileNotFoundError:
167+
raise HTTPException(
168+
status_code=404, detail=f"'{project_dir}' missing"
169+
) from None
170+
content = self.html_project.format(
171+
project=quote(project), entries="\n".join(dirs)
172+
)
173+
return HTMLResponse(content=content)
174+
175+
async def server_file(self, request: Request) -> Response:
176+
project = request.path_params["project"]
177+
filename = request.path_params["filename"]
178+
179+
path: pathlib.Path = self.basedir / project / filename
180+
try:
181+
stat_result = path.stat(follow_symlinks=True)
182+
except FileNotFoundError:
183+
raise HTTPException(status_code=404, detail="File not found") from None
184+
if not stat.S_ISREG(stat_result.st_mode):
185+
raise HTTPException(status_code=400, detail="Not a regular file")
186+
187+
if filename.endswith(".tar.gz"):
188+
media_type = "application/x-tar"
189+
elif filename.endswith(".whl"):
190+
media_type = "application/zip"
191+
elif filename.endswith(".whl.metadata"):
192+
media_type = "binary/octet-stream"
193+
else:
194+
raise HTTPException(status_code=400, detail="Bad request")
195+
196+
return FileResponse(path, media_type=media_type, stat_result=stat_result)
197+
198+
199+
def make_app(basedir: pathlib.Path) -> Starlette:
200+
"""Create a Starlette app with routing"""
201+
si = SimpleHTMLIndex(basedir)
202+
routes: list[Route] = [
203+
Route("/", endpoint=si.root),
204+
Route("/simple", endpoint=si.index_page),
205+
Route("/simple/{project:str}", endpoint=si.project_page),
206+
Route("/simple/{project:str}/{filename:str}", endpoint=si.server_file),
207+
]
208+
return Starlette(routes=routes)
209+
210+
211+
def _run_background_thread(
212+
loop: asyncio.AbstractEventLoop,
213+
app: Starlette,
214+
host="127.0.0.1",
215+
port=0,
216+
**kwargs,
217+
) -> tuple[uvicorn.Server, socket.socket, threading.Thread]:
218+
"""Run uvicorn server in a daemon thread"""
219+
config = uvicorn.Config(app=app, host=host, port=port, **kwargs)
220+
server = uvicorn.Server(config=config)
221+
sock = server.config.bind_socket()
222+
223+
def _run_background() -> None:
224+
asyncio.set_event_loop(loop)
225+
loop.run_until_complete(server.serve(sockets=[sock]))
226+
227+
thread = threading.Thread(target=_run_background, args=(), daemon=True)
228+
thread.start()
229+
return server, sock, thread
230+
231+
232+
def stop_server(server: uvicorn.Server, loop: asyncio.AbstractEventLoop) -> None:
233+
"""Stop server, blocks until server is shut down"""
234+
fut = asyncio.run_coroutine_threadsafe(server.shutdown(), loop=loop)
235+
fut.result()

0 commit comments

Comments
 (0)