11from __future__ import annotations
22
3- import functools
4- import http .server
3+ import asyncio
54import logging
65import os
76import pathlib
87import shutil
8+ import socket
9+ import stat
10+ import textwrap
911import threading
1012import typing
13+ from urllib .parse import quote
1114
15+ import uvicorn
1216from 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
1423from .threading_utils import with_thread_lock
1524
1928logger = 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-
2731def 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
3539def 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