11from __future__ import annotations
22
3- import functools
4- import http .server
5- import io
3+ import asyncio
64import logging
75import os
86import pathlib
97import shutil
8+ import socket
9+ import stat
10+ import textwrap
1011import threading
1112import typing
13+ from urllib .parse import quote
1214
15+ import uvicorn
1316from 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
1523from .threading_utils import with_thread_lock
1624
2028logger = 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-
4131def 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
4939def 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