Skip to content

Commit d052402

Browse files
authored
Merge pull request #774 from tiran/starlette-pypi
feat: Simple PyPI server with Starlette
2 parents 4eba815 + 63b8673 commit d052402

File tree

3 files changed

+175
-46
lines changed

3 files changed

+175
-46
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 & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,24 @@
11
from __future__ import annotations
22

3-
import functools
4-
import http.server
5-
import io
3+
import asyncio
64
import logging
75
import os
86
import pathlib
97
import shutil
8+
import socket
9+
import stat
10+
import textwrap
1011
import threading
1112
import typing
13+
from urllib.parse import quote
1214

15+
import uvicorn
1316
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
1422

1523
from .threading_utils import with_thread_lock
1624

@@ -20,24 +28,6 @@
2028
logger = logging.getLogger(__name__)
2129

2230

23-
class LoggingHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
24-
def log_message(self, format: str, *args: typing.Any) -> None:
25-
logger.debug(format, *args)
26-
27-
def list_directory(self, path: str | os.PathLike[str]) -> io.BytesIO | None:
28-
# default list_directory() function appends an "@" to every symbolic
29-
# link. pypi_simple does not understand the "@". Rewrite the body
30-
# while keeping the same content length.
31-
old: io.BytesIO | None = super().list_directory(path)
32-
if old is None:
33-
return None
34-
new = io.BytesIO()
35-
for oldline in old:
36-
new.write(oldline.replace(b"@</a>", b"</a> "))
37-
new.seek(0)
38-
return new
39-
40-
4131
def start_wheel_server(ctx: context.WorkContext) -> None:
4232
update_wheel_mirror(ctx)
4333
if ctx.wheel_server_url:
@@ -48,33 +38,24 @@ def start_wheel_server(ctx: context.WorkContext) -> None:
4838

4939
def run_wheel_server(
5040
ctx: context.WorkContext,
51-
address: str = "localhost",
41+
address: str = "127.0.0.1",
5242
port: int = 0,
53-
) -> threading.Thread:
54-
server = http.server.ThreadingHTTPServer(
55-
(address, port),
56-
functools.partial(LoggingHTTPRequestHandler, directory=str(ctx.wheels_repo)),
57-
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
5852
)
59-
server.timeout = 0.5
60-
server.allow_reuse_address = True
6153

62-
logger.debug(f"address {server.server_address}")
63-
server.server_bind()
64-
ctx.wheel_server_url = f"http://{address}:{server.server_port}/simple/"
54+
realport = sock.getsockname()[1]
55+
ctx.wheel_server_url = f"http://{address}:{realport}/simple/"
6556

66-
logger.debug("starting wheel server at %s", ctx.wheel_server_url)
67-
server.server_activate()
68-
69-
def serve_forever(server: http.server.ThreadingHTTPServer) -> None:
70-
# ensure server.server_close() is called
71-
with server:
72-
server.serve_forever()
73-
74-
t = threading.Thread(target=serve_forever, args=(server,))
75-
t.setDaemon(True)
76-
t.start()
77-
return t
57+
logger.info("started wheel server at %s", ctx.wheel_server_url)
58+
return server, sock, thread
7859

7960

8061
@with_thread_lock()
@@ -106,3 +87,149 @@ def update_wheel_mirror(ctx: context.WorkContext) -> None:
10687
logger.debug("linking %s -> %s into local index", wheel.name, relpath)
10788
simple_dest_filename.parent.mkdir(parents=True, exist_ok=True)
10889
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)