|
| 1 | +import hashlib |
1 | 2 | import os |
2 | 3 | import subprocess |
3 | 4 | import sys |
|
14 | 15 | UPLOAD_KEYFILE = os.getenv("UPLOAD_KEYFILE", "") |
15 | 16 | UPLOAD_USER = os.getenv("UPLOAD_USER", "") |
16 | 17 | NO_UPLOAD = os.getenv("NO_UPLOAD", "no")[:1].lower() in "yt1" |
| 18 | +RELEASE_API_URL = os.getenv("RELEASE_API_URL", "https://www.python.org/api").rstrip("/") |
| 19 | +RELEASE_API_KEY = os.getenv("RELEASE_API_KEY") |
| 20 | + |
| 21 | +RELEASE_API_HEADERS = {"Content-Type": "application/json; charset=utf-8"} |
| 22 | +if RELEASE_API_KEY: |
| 23 | + RELEASE_API_HEADERS["Authorization"] = f"ApiKey {RELEASE_API_KEY}" |
17 | 24 |
|
18 | 25 | # Set to 'true' when updating index.json, rather than the app |
19 | 26 | UPLOADING_INDEX = os.getenv("UPLOADING_INDEX", "no")[:1].lower() in "yt1" |
@@ -126,6 +133,14 @@ def url2path(url): |
126 | 133 | return UPLOAD_PATH_PREFIX + url[len(UPLOAD_URL_PREFIX) :] |
127 | 134 |
|
128 | 135 |
|
| 136 | +def sha256_for(file): |
| 137 | + h = hashlib.sha256() |
| 138 | + with open(file, "rb") as f: |
| 139 | + while b := f.read(1024 * 1024): |
| 140 | + h.update(b) |
| 141 | + return h.hexdigest().upper() |
| 142 | + |
| 143 | + |
129 | 144 | def appinstaller_uri_matches(file, name): |
130 | 145 | NS = {} |
131 | 146 | with open(file, "r", encoding="utf-8") as f: |
@@ -186,6 +201,101 @@ def validate_appinstaller(file, uploads): |
186 | 201 | print() |
187 | 202 |
|
188 | 203 |
|
| 204 | +_get_release_id_cache = {} |
| 205 | +def get_release_id(**params): |
| 206 | + if not RELEASE_API_URL: |
| 207 | + raise RuntimeError("Cannot query object when RELEASE_API_URL is not set") |
| 208 | + uri = f"{RELEASE_API_URL}/v1/downloads/release/" |
| 209 | + uri += "?" + "&".join(f"{k}={v}" for k, v in params.items()) |
| 210 | + try: |
| 211 | + return _get_release_id_cache[uri] |
| 212 | + except KeyError: |
| 213 | + pass |
| 214 | + req = Request(uri, method="GET", headers=RELEASE_API_HEADERS) |
| 215 | + with urlopen(req) as resp: |
| 216 | + if resp.status != 200: |
| 217 | + raise RuntimeError(f"no release for {params!r}: Status {resp.status}") |
| 218 | + obj = json.loads(resp.read())["objects"][0] |
| 219 | + _get_release_id_cache[url] = obj["resource_url"] |
| 220 | + return obj["resource_uri"] |
| 221 | + |
| 222 | + |
| 223 | +def calculate_release_file(file, url, upload_path): |
| 224 | + if not file: |
| 225 | + return |
| 226 | + if not url: |
| 227 | + print("Skipping", file, "as no URL was provided") |
| 228 | + return |
| 229 | + m = re.match(r".*?(\d+(?:\.\d+)*(?:(?:a|b|rc)?\d+))$", file.stem) |
| 230 | + if not m: |
| 231 | + print("Skipping", file, "as no version was found in filename") |
| 232 | + return |
| 233 | + slug = m.group(1).replace(".", "") |
| 234 | + rel_pk = get_release_id(slug=f"pymanager-{slug}") |
| 235 | + if not rel_pk: |
| 236 | + print("Skipping", slug, "as no release was found") |
| 237 | + return |
| 238 | + data = { |
| 239 | + "os": "/api/v1/downloads/os/windows/", |
| 240 | + "is_source": False, |
| 241 | + "url": url, |
| 242 | + "release": rel_pk, |
| 243 | + "sha256_sum": sha256sum_for(filename), |
| 244 | + "filesize": file.stat().st_size, |
| 245 | + "download_button": False, |
| 246 | + } |
| 247 | + if file.match("*.msix"): |
| 248 | + return { |
| 249 | + **data, |
| 250 | + "name": "Installer (MSIX)", |
| 251 | + "slug": f"pymanager-{slug}-msix", |
| 252 | + "description": f"Bundles Python {BUNDLED_RUNTIME_VERSION}", |
| 253 | + "download_button": True, |
| 254 | + } |
| 255 | + if file.match("*.msi"): |
| 256 | + return { |
| 257 | + **data, |
| 258 | + "name": "MSI package", |
| 259 | + "slug": f"pymanager-{slug}-msi", |
| 260 | + "description": "See documentation before use", |
| 261 | + } |
| 262 | + |
| 263 | + |
| 264 | +def publish_release_files(file_data): |
| 265 | + if not file_data: |
| 266 | + return |
| 267 | + print("Publishing:") |
| 268 | + print(json.dumps(file_data, indent=2)) |
| 269 | + if NO_UPLOAD: |
| 270 | + print("Skipping release files due to NO_UPLOAD") |
| 271 | + return |
| 272 | + if not RELEASE_API_URL: |
| 273 | + raise RuntimeError("Cannot publish object when RELEASE_API_URL is not set") |
| 274 | + if not RELEASE_API_KEY: |
| 275 | + raise RuntimeError("Cannot publish object when RELEASE_API_KEY is not set") |
| 276 | + rel_pk = int(file_data["release"].rstrip("/").rpartition("/")[2] |
| 277 | + print("Deleting files from release", rel) |
| 278 | + u = f"{RELEASE_API_URL}/v1/downloads/release_file/?release={rel}" |
| 279 | + req = Request(u, method="DELETE", headers=RELEASE_API_HEADERS) |
| 280 | + with urlopen(req) as r: |
| 281 | + if 200 <= r.status < 300: |
| 282 | + print(f"Deleted successfully (status={r.status}).") |
| 283 | + else: |
| 284 | + print(f"Failed to delete (status={r.status}). Attempting to publish anyway.") |
| 285 | + |
| 286 | + print("Publishing release file") |
| 287 | + u = f"{RELEASE_API_URL}/v1/downloads/release_file/" |
| 288 | + data = json.dumps(file_data).encode("utf-8") |
| 289 | + req = Request(u, method="POST", data=data, headers=RELEASE_API_HEADERS) |
| 290 | + with urlopen(req) as r: |
| 291 | + if 200 <= r.status < 300: |
| 292 | + print(f"Created successfully (status={r.status}).") |
| 293 | + else: |
| 294 | + print(f"Failed to create (status={r.status}).") |
| 295 | + print("Publishing complete") |
| 296 | + |
| 297 | + |
| 298 | + |
189 | 299 | def purge(url): |
190 | 300 | if not UPLOAD_HOST or NO_UPLOAD: |
191 | 301 | print("Skipping purge of", url, "because UPLOAD_HOST is missing") |
@@ -242,3 +352,15 @@ def purge(url): |
242 | 352 |
|
243 | 353 | # Purge the upload directory so that the FTP browser is up to date |
244 | 354 | purge(UPLOAD_URL) |
| 355 | + |
| 356 | + |
| 357 | +if RELEASE_API_URL: |
| 358 | + files = [] |
| 359 | + for f, u, p in UPLOADS: |
| 360 | + fd = calculate_release_file(f, u, p) |
| 361 | + if fd: |
| 362 | + files.append(fd) |
| 363 | + |
| 364 | + print("Releasing", len(files), "files") |
| 365 | + for fd in files: |
| 366 | + publish_release_file(fd) |
0 commit comments