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