11#!/usr/bin/env python3
2+ # SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
3+ #
4+ # SPDX-License-Identifier: Apache-2.0
5+
26"""Local GitHub Releases API overlay for dstackup development.
37
48Locally published releases shadow GitHub. Requests not satisfied locally are
2226
2327
2428class State :
29+ """Thread-safe-by-snapshot in-memory release overlay."""
30+
2531 def __init__ (self , asset_dir : Path ):
32+ """Create an empty overlay and its local asset directory."""
2633 self .asset_dir = asset_dir .resolve ()
2734 self .asset_dir .mkdir (parents = True , exist_ok = True )
2835 self .releases = {} # repo -> tag -> release; insertion order is newest first
2936
3037 def publish (self , repo , release , public_base ):
38+ """Publish a release as the newest local release for a repository."""
3139 tag = release .get ("tag_name" )
3240 if not isinstance (tag , str ) or not tag :
3341 raise ValueError ("tag_name must be a non-empty string" )
@@ -49,19 +57,28 @@ def publish(self, repo, release, public_base):
4957 digest = f"sha256:{ digest } " ,
5058 )
5159 if not asset .get ("name" ) or not asset .get ("browser_download_url" ):
52- raise ValueError ("each asset needs name and browser_download_url, or local_path" )
60+ raise ValueError (
61+ "each asset needs name and browser_download_url, or local_path"
62+ )
5363 assets .append (asset )
5464 release = dict (release , assets = assets )
55- releases = self .releases .setdefault (repo , {})
56- releases .pop (tag , None )
57- self .releases [repo ] = {tag : release , ** releases }
65+ # Build a new snapshot rather than mutating one observed by a concurrent
66+ # GET handler. Assignment is atomic under CPython's GIL.
67+ releases = self .releases .get (repo , {})
68+ self .releases [repo ] = {
69+ tag : release ,
70+ ** {old_tag : old for old_tag , old in releases .items () if old_tag != tag },
71+ }
5872 return release
5973
6074
6175class Handler (BaseHTTPRequestHandler ):
76+ """Serve the local release overlay and relay misses to GitHub."""
77+
6278 server_version = "dstack-release-mock/1"
6379
6480 def json_response (self , status , value ):
81+ """Send a JSON response with a fixed content length."""
6582 body = json .dumps (value , indent = 2 ).encode ()
6683 self .send_response (status )
6784 self .send_header ("content-type" , "application/json" )
@@ -70,9 +87,14 @@ def json_response(self, status, value):
7087 self .wfile .write (body )
7188
7289 def do_POST (self ):
90+ """Publish a release through the local administration endpoint."""
7391 # POST /__admin/repos/{owner}/{repo}/releases
7492 parts = self .path .strip ("/" ).split ("/" )
75- if len (parts ) != 5 or parts [:2 ] != ["__admin" , "repos" ] or parts [4 :] != ["releases" ]:
93+ if (
94+ len (parts ) != 5
95+ or parts [:2 ] != ["__admin" , "repos" ]
96+ or parts [4 :] != ["releases" ]
97+ ):
7698 self .send_error (404 )
7799 return
78100 try :
@@ -87,6 +109,7 @@ def do_POST(self):
87109 self .json_response (400 , {"error" : str (error )})
88110
89111 def do_GET (self ):
112+ """Serve an asset or a GitHub-compatible releases API request."""
90113 path = urlsplit (self .path ).path
91114 if path .startswith ("/__assets/" ):
92115 self .serve_asset (unquote (path .removeprefix ("/__assets/" )))
@@ -102,7 +125,9 @@ def do_GET(self):
102125 upstream = self .fetch_upstream_json ()
103126 if isinstance (upstream , list ):
104127 local_tags = set (local )
105- upstream = [r for r in upstream if r .get ("tag_name" ) not in local_tags ]
128+ upstream = [
129+ r for r in upstream if r .get ("tag_name" ) not in local_tags
130+ ]
106131 else :
107132 upstream = []
108133 self .json_response (200 , list (local .values ()) + upstream )
@@ -118,6 +143,7 @@ def do_GET(self):
118143 self .relay ()
119144
120145 def serve_asset (self , name ):
146+ """Serve one basename-only file from the configured asset directory."""
121147 if not name or Path (name ).name != name :
122148 self .send_error (400 , "invalid asset name" )
123149 return
@@ -126,65 +152,92 @@ def serve_asset(self, name):
126152 self .send_error (404 )
127153 return
128154 self .send_response (200 )
129- self .send_header ("content-type" , mimetypes .guess_type (name )[0 ] or "application/octet-stream" )
155+ self .send_header (
156+ "content-type" , mimetypes .guess_type (name )[0 ] or "application/octet-stream"
157+ )
130158 self .send_header ("content-length" , str (path .stat ().st_size ))
131159 self .end_headers ()
132160 with path .open ("rb" ) as source :
133161 shutil .copyfileobj (source , self .wfile )
134162
135163 def upstream_request (self ):
164+ """Build the corresponding authenticated GitHub API request."""
136165 url = UPSTREAM + self .path
137- headers = {"user-agent" : "dstack-release-mock" , "accept" : "application/vnd.github+json" }
166+ headers = {
167+ "user-agent" : "dstack-release-mock" ,
168+ "accept" : "application/vnd.github+json" ,
169+ }
138170 auth = self .headers .get ("authorization" )
139171 if auth :
140172 headers ["authorization" ] = auth
141173 return urllib .request .Request (url , headers = headers )
142174
143175 def fetch_upstream_json (self ):
176+ """Fetch upstream JSON for merging, returning an empty list on failure."""
144177 try :
145- with urllib .request .urlopen (self .upstream_request (), timeout = 30 ) as response :
178+ with urllib .request .urlopen (
179+ self .upstream_request (), timeout = 30
180+ ) as response :
146181 return json .load (response )
147182 except (urllib .error .URLError , json .JSONDecodeError ):
148183 return []
149184
150185 def relay (self ):
186+ """Relay the current request and its HTTP status to GitHub."""
151187 try :
152- with urllib .request .urlopen (self .upstream_request (), timeout = 30 ) as response :
188+ with urllib .request .urlopen (
189+ self .upstream_request (), timeout = 30
190+ ) as response :
153191 body = response .read ()
154192 self .send_response (response .status )
155- self .send_header ("content-type" , response .headers .get ("content-type" , "application/json" ))
193+ self .send_header (
194+ "content-type" ,
195+ response .headers .get ("content-type" , "application/json" ),
196+ )
156197 self .send_header ("content-length" , str (len (body )))
157198 self .end_headers ()
158199 self .wfile .write (body )
159200 except urllib .error .HTTPError as error :
160201 body = error .read ()
161202 self .send_response (error .code )
162- self .send_header ("content-type" , error .headers .get ("content-type" , "application/json" ))
203+ self .send_header (
204+ "content-type" , error .headers .get ("content-type" , "application/json" )
205+ )
163206 self .send_header ("content-length" , str (len (body )))
164207 self .end_headers ()
165208 self .wfile .write (body )
166209 except urllib .error .URLError as error :
167210 self .json_response (502 , {"error" : f"upstream request failed: { error } " })
168211
169212 def log_message (self , fmt , * args ):
213+ """Write HTTP access logs to stderr."""
170214 sys .stderr .write ("%s - %s\n " % (self .address_string (), fmt % args ))
171215
172216
173217class Server (ThreadingHTTPServer ):
218+ """Threading HTTP server carrying shared overlay state."""
219+
174220 def __init__ (self , address , state ):
221+ """Bind the server and attach its overlay state."""
175222 super ().__init__ (address , Handler )
176223 self .state = state
177224
178225
179226def main ():
227+ """Run the local releases API overlay until interrupted."""
180228 parser = argparse .ArgumentParser (description = __doc__ )
181229 parser .add_argument ("--listen" , default = "127.0.0.1" )
182230 parser .add_argument ("--port" , type = int , default = 8000 )
183- parser .add_argument ("--asset-dir" , type = Path , default = Path ("/tmp/dstack-release-mock-assets" ))
231+ parser .add_argument (
232+ "--asset-dir" , type = Path , default = Path ("/tmp/dstack-release-mock-assets" )
233+ )
184234 args = parser .parse_args ()
185235 server = Server ((args .listen , args .port ), State (args .asset_dir ))
186236 print (f"release API: http://{ args .listen } :{ args .port } /repos" , flush = True )
187- print (f"publish API: http://{ args .listen } :{ args .port } /__admin/repos/OWNER/REPO/releases" , flush = True )
237+ print (
238+ f"publish API: http://{ args .listen } :{ args .port } /__admin/repos/OWNER/REPO/releases" ,
239+ flush = True ,
240+ )
188241 try :
189242 server .serve_forever ()
190243 except KeyboardInterrupt :
0 commit comments