From 854f17a1101c1f44ca3fb0ff55046d41e85cd03e Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 22 Sep 2026 14:45:15 +0800 Subject: [PATCH 1/7] Add python.org API helpers for resolving official release data --- scripts/update_support.py | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/scripts/update_support.py b/scripts/update_support.py index e6a3f03a..f5b828b7 100644 --- a/scripts/update_support.py +++ b/scripts/update_support.py @@ -98,6 +98,39 @@ def _apple_support( return revisions, hashes +# --- macOS / iOS: official python.org release-data API (3.15+) ------------- + +PYTHON_ORG_API_ROOT = "https://www.python.org/api/v1" +PYTHON_ORG_LATEST_URL = "https://www.python.org/downloads/latest/python{tag}/" + + +def _python_org_api_get(url: str, opener) -> object: + """GET `url` (a python.org /api/v1/ endpoint) and return the parsed JSON + body. Unlike _github.py's api_get, no auth header is needed or sent -- + python.org's public downloads API is unauthenticated.""" + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + with opener(request) as response: + return json.load(response) + + +def _resolve_release_slug(tag: str, opener) -> str: + """The python.org release slug (e.g. "python-3150rc2") for the latest + release of Python `tag` (e.g. "3.15"), resolved via the redirect target + of the public "latest" URL -- avoids needing a "starts with" filter that + the public release API doesn't expose.""" + request = urllib.request.Request( + PYTHON_ORG_LATEST_URL.format(tag=tag), method="HEAD" + ) + with opener(request) as response: + final_url = response.geturl() + match = re.search(r"/downloads/release/(?P[^/]+)/?$", final_url) + if not match: + raise ValueError( + f"Unexpected redirect target resolving latest Python {tag}: {final_url}" + ) + return match.group("slug") + + # --- Windows: python.org embeddable-package index ---------------------------- WINDOWS_INDEX_URL = "https://www.python.org/ftp/python/index-windows.json" From 3812aaa8c46f199b44f17e9fa3eb85b25ba297c2 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 22 Sep 2026 14:52:05 +0800 Subject: [PATCH 2/7] Source iOS 3.15+ support-package data from the official python.org API --- scripts/update_support.py | 98 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/scripts/update_support.py b/scripts/update_support.py index f5b828b7..85387242 100644 --- a/scripts/update_support.py +++ b/scripts/update_support.py @@ -131,6 +131,77 @@ def _resolve_release_slug(tag: str, opener) -> str: return match.group("slug") +# Platforms with an official-CPython-source support package, the first +# Python (major, minor) for which that source is authoritative, and the +# python.org `OS.slug` identifying the relevant release-file row. Only iOS +# is populated today; adding macOS later (once python.org's macOS installer, +# or some other officially published macOS artifact, becomes the support +# package source) is just adding an entry to each of these two dicts. +OFFICIAL_SOURCE_MIN_VERSION: dict[str, tuple[int, int]] = { + "iOS": (3, 15), +} +OFFICIAL_SOURCE_OS_SLUG: dict[str, str] = { + "iOS": "ios", +} + + +def _official_cpython_support( + platform: str, + tags: set[str], + opener, +) -> tuple[dict[str, str], dict[str, str]]: + """Flat revisions/hashes sourced from python.org's own release-data API, + for platforms/tags that have moved off Python-Apple-support.""" + os_slug = OFFICIAL_SOURCE_OS_SLUG[platform] + + revisions: dict[str, str] = {} + hashes: dict[str, str] = {} + for tag in sorted(tags): + try: + slug = _resolve_release_slug(tag, opener) + release = _python_org_api_get( + f"{PYTHON_ORG_API_ROOT}/downloads/release/?format=json&slug={slug}", + opener, + )["objects"][0] + except Exception as e: + print( + f"warning: could not resolve latest release for Python {tag} " + f"({e}); leaving unchanged", + file=sys.stderr, + ) + continue + + prefix = f"Python {tag}." + if not release["name"].startswith(prefix): + print( + f"warning: unexpected release name {release['name']!r} for " + f"Python {tag}; leaving unchanged", + file=sys.stderr, + ) + continue + revision = release["name"][len(prefix) :] + + files = _python_org_api_get( + f"{PYTHON_ORG_API_ROOT}/downloads/release_file/?format=json" + f"&release__slug={slug}&os__slug={os_slug}", + opener, + )["objects"] + if len(files) != 1: + print( + f"warning: expected exactly one {platform} release file for " + f"Python {tag} ({slug}), found {len(files)}; leaving unchanged", + file=sys.stderr, + ) + continue + digest = files[0]["sha256_sum"] + + revisions[tag] = revision + hashes[tag] = f"sha256:{digest}" + print(f"{tag}: support_revision = {revision}, sha256:{digest}") + + return revisions, hashes + + # --- Windows: python.org embeddable-package index ---------------------------- WINDOWS_INDEX_URL = "https://www.python.org/ftp/python/index-windows.json" @@ -331,7 +402,32 @@ def update(template_dir: Path, opener=urllib.request.urlopen) -> None: to_delete: set[int] = set() if platform in {"macOS", "iOS"}: - revisions, hashes = _apple_support(platform, tags, opener) + min_version = OFFICIAL_SOURCE_MIN_VERSION.get(platform) + if min_version is not None: + official_tags = { + tag + for tag in tags + if tuple(int(part) for part in tag.split(".")) >= min_version + } + else: + official_tags = set() + legacy_tags = tags - official_tags + + revisions: dict[str, str] = {} + hashes: dict[str, str] = {} + if legacy_tags: + legacy_revisions, legacy_hashes = _apple_support( + platform, legacy_tags, opener + ) + revisions.update(legacy_revisions) + hashes.update(legacy_hashes) + if official_tags: + official_revisions, official_hashes = _official_cpython_support( + platform, official_tags, opener + ) + revisions.update(official_revisions) + hashes.update(official_hashes) + to_delete |= apply_updates(lines, REVISION_ENTRY_RE, REVISION_KEY, revisions) to_delete |= apply_updates(lines, HASH_ENTRY_RE, HASH_KEY, hashes) From 1b3087957259447e16daf76f052dee428423c29f Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 22 Sep 2026 14:56:59 +0800 Subject: [PATCH 3/7] Document the official-source iOS 3.15+ support-package path --- scripts/update_support.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/update_support.py b/scripts/update_support.py index 85387242..310f6e1d 100644 --- a/scripts/update_support.py +++ b/scripts/update_support.py @@ -10,7 +10,11 @@ (see platforms.py), and used to select the correct upstream data source: - macOS / iOS: GitHub releases of `beeware/Python-Apple-support` - (per-Python-version release tags, e.g. `3.14-b11`). + (per-Python-version release tags, e.g. `3.14-b11`), for Python versions + before each platform's official-source cutover (see + `OFFICIAL_SOURCE_MIN_VERSION`). For iOS, Python 3.15 and later instead use + official CPython release artifacts published via python.org's public + downloads API (https://www.python.org/api/v1/downloads/). - Windows: the Windows embeddable-package index published at https://www.python.org/ftp/python/index-windows.json, per AMD64/ARM64 host architecture. From ff522a2fc25599d26af349f9d390844c2dfbb3eb Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 22 Sep 2026 15:16:02 +0800 Subject: [PATCH 4/7] Fix unhandled exceptions in _official_cpython_support's release-file fetch The try/except block in the per-tag loop only wrapped the first two operations (_resolve_release_slug and the release lookup). A failure anywhere in the second half of the loop -- the release["name"] access, the release_file API call, or the files[0]["sha256_sum"] access -- was unprotected and would raise out of update(), aborting the entire script instead of just skipping the one problematic tag. Widen the try block to cover the whole per-tag body. The two existing if-based warnings (unexpected release name, wrong file count) keep their distinct messages via continue-inside-try (which skips the except), while any other unexpected exception (network errors, KeyError, IndexError, json.JSONDecodeError, etc.) from the fetch calls is now caught by the trailing except Exception and reported with the same generic warning-and-continue behavior as before. --- scripts/update_support.py | 48 +++++++++++++++++++-------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/scripts/update_support.py b/scripts/update_support.py index 310f6e1d..6f97f09b 100644 --- a/scripts/update_support.py +++ b/scripts/update_support.py @@ -167,6 +167,30 @@ def _official_cpython_support( f"{PYTHON_ORG_API_ROOT}/downloads/release/?format=json&slug={slug}", opener, )["objects"][0] + + prefix = f"Python {tag}." + if not release["name"].startswith(prefix): + print( + f"warning: unexpected release name {release['name']!r} for " + f"Python {tag}; leaving unchanged", + file=sys.stderr, + ) + continue + revision = release["name"][len(prefix) :] + + files = _python_org_api_get( + f"{PYTHON_ORG_API_ROOT}/downloads/release_file/?format=json" + f"&release__slug={slug}&os__slug={os_slug}", + opener, + )["objects"] + if len(files) != 1: + print( + f"warning: expected exactly one {platform} release file for " + f"Python {tag} ({slug}), found {len(files)}; leaving unchanged", + file=sys.stderr, + ) + continue + digest = files[0]["sha256_sum"] except Exception as e: print( f"warning: could not resolve latest release for Python {tag} " @@ -175,30 +199,6 @@ def _official_cpython_support( ) continue - prefix = f"Python {tag}." - if not release["name"].startswith(prefix): - print( - f"warning: unexpected release name {release['name']!r} for " - f"Python {tag}; leaving unchanged", - file=sys.stderr, - ) - continue - revision = release["name"][len(prefix) :] - - files = _python_org_api_get( - f"{PYTHON_ORG_API_ROOT}/downloads/release_file/?format=json" - f"&release__slug={slug}&os__slug={os_slug}", - opener, - )["objects"] - if len(files) != 1: - print( - f"warning: expected exactly one {platform} release file for " - f"Python {tag} ({slug}), found {len(files)}; leaving unchanged", - file=sys.stderr, - ) - continue - digest = files[0]["sha256_sum"] - revisions[tag] = revision hashes[tag] = f"sha256:{digest}" print(f"{tag}: support_revision = {revision}, sha256:{digest}") From cae89c16262884ba1e10c0b0df7c4f2a3a9e13db Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 22 Sep 2026 15:34:28 +0800 Subject: [PATCH 5/7] Narrow the release-fetch exception handler to satisfy ruff's BLE001 Catching bare Exception triggered ruff's blind-except lint rule. Narrow to the concrete exception types the per-tag fetch/parse sequence can actually raise: urllib.error.URLError (network failures), ValueError (includes json.JSONDecodeError, and the explicit resolve_release_slug raise), KeyError (missing dict keys in a malformed API response), and IndexError (an empty objects list). Verified via the same happy-path and fault-injection scripts used for the original fix, plus additional KeyError/IndexError fault-injection cases. --- scripts/update_support.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/update_support.py b/scripts/update_support.py index 6f97f09b..8c58b38f 100644 --- a/scripts/update_support.py +++ b/scripts/update_support.py @@ -37,6 +37,7 @@ import json import re import sys +import urllib.error import urllib.request from pathlib import Path @@ -191,7 +192,7 @@ def _official_cpython_support( ) continue digest = files[0]["sha256_sum"] - except Exception as e: + except (urllib.error.URLError, ValueError, KeyError, IndexError) as e: print( f"warning: could not resolve latest release for Python {tag} " f"({e}); leaving unchanged", From c9523f718174746e7b468684b13fb8916e116bdf Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 22 Sep 2026 15:57:52 +0800 Subject: [PATCH 6/7] Replace per-tag redirect resolution with bulk python.org API queries The previous approach (_resolve_release_slug) issued a HEAD request per tag to python.org's human-facing /downloads/latest/python{tag}/ URL and relied on its redirect target to find the release slug, working around the public release API's lack of a 'starts with' filter on name/slug. This depended on undocumented website routing behavior rather than a documented API contract, and cost 3 requests per tag (HEAD + release + release_file). Replace it with two bulk requests total, regardless of tag count: - GET .../downloads/release/?version=3&is_published=true&limit=0 for every published Python 3.x release (limit=0 is Tastypie's convention for 'return everything', confirmed to return all 398 objects in one response). - GET .../downloads/release_file/?os__slug=&limit=0 for every release-file row for the platform's OS. Each tag's latest matching release is then picked out client-side by filtering on the 'Python {tag}.' name prefix and taking the max by release_date, and its release file is looked up by resource_uri in a dict built from the second bulk response. This only uses documented query-param filters (version, is_published, os__slug) rather than an exact-match slug filter paired with a redirect trick. Also widen error handling to cover the entire per-tag body (release filtering, file lookup, and both dict-key extractions), not just the final digest access -- a malformed release object missing 'name' or 'release_date' previously crashed the whole run instead of warning and skipping just that tag, the same class of bug fixed in ff522a2 for the release-file half of the old per-tag loop. Fix a message bug found while re-verifying: the 'no release file found' warning quoted release['name'] (which already starts with 'Python '), producing a doubled 'for Python Python 3.11.16' in the message. Verified via the same manual scripts as before (live happy path, multi-tag with some tags lacking an iOS file, bulk-fetch network failure, malformed release_file response, missing sha256_sum, and a new malformed-release-object case), plus the full update() dispatch smoke test and a real briefcase-iOS-Xcode-template dry run -- all producing identical, correct output to the pre-redesign implementation. --- scripts/update_support.py | 95 ++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 42 deletions(-) diff --git a/scripts/update_support.py b/scripts/update_support.py index 8c58b38f..94c02a53 100644 --- a/scripts/update_support.py +++ b/scripts/update_support.py @@ -106,34 +106,21 @@ def _apple_support( # --- macOS / iOS: official python.org release-data API (3.15+) ------------- PYTHON_ORG_API_ROOT = "https://www.python.org/api/v1" -PYTHON_ORG_LATEST_URL = "https://www.python.org/downloads/latest/python{tag}/" def _python_org_api_get(url: str, opener) -> object: """GET `url` (a python.org /api/v1/ endpoint) and return the parsed JSON body. Unlike _github.py's api_get, no auth header is needed or sent -- - python.org's public downloads API is unauthenticated.""" - request = urllib.request.Request(url, headers={"Accept": "application/json"}) - with opener(request) as response: - return json.load(response) - + python.org's public downloads API is unauthenticated. -def _resolve_release_slug(tag: str, opener) -> str: - """The python.org release slug (e.g. "python-3150rc2") for the latest - release of Python `tag` (e.g. "3.15"), resolved via the redirect target - of the public "latest" URL -- avoids needing a "starts with" filter that - the public release API doesn't expose.""" + `limit=0` is appended -- Tastypie's convention for "return every + matching object" -- so the full release/release-file list is fetched in + one request rather than paginating.""" request = urllib.request.Request( - PYTHON_ORG_LATEST_URL.format(tag=tag), method="HEAD" + f"{url}&limit=0", headers={"Accept": "application/json"} ) with opener(request) as response: - final_url = response.geturl() - match = re.search(r"/downloads/release/(?P[^/]+)/?$", final_url) - if not match: - raise ValueError( - f"Unexpected redirect target resolving latest Python {tag}: {final_url}" - ) - return match.group("slug") + return json.load(response) # Platforms with an official-CPython-source support package, the first @@ -156,45 +143,69 @@ def _official_cpython_support( opener, ) -> tuple[dict[str, str], dict[str, str]]: """Flat revisions/hashes sourced from python.org's own release-data API, - for platforms/tags that have moved off Python-Apple-support.""" + for platforms/tags that have moved off Python-Apple-support. + + Two bulk requests cover every tag: one for every published Python 3.x + release (there's no server-side "latest release for X.Y" filter, so the + latest-per-tag is picked out client-side by release_date), and one for + every release-file row for this platform's OS. This is two requests + total regardless of how many tags are being resolved -- looking up one + tag at a time would mean two requests, plus a HEAD-redirect trick to + work around the API's missing "starts with" filter, per tag.""" os_slug = OFFICIAL_SOURCE_OS_SLUG[platform] revisions: dict[str, str] = {} hashes: dict[str, str] = {} - for tag in sorted(tags): - try: - slug = _resolve_release_slug(tag, opener) - release = _python_org_api_get( - f"{PYTHON_ORG_API_ROOT}/downloads/release/?format=json&slug={slug}", + try: + releases = _python_org_api_get( + f"{PYTHON_ORG_API_ROOT}/downloads/release/?format=json" + "&version=3&is_published=true", + opener, + )["objects"] + files_by_release_uri = { + file["release"]: file + for file in _python_org_api_get( + f"{PYTHON_ORG_API_ROOT}/downloads/release_file/?format=json" + f"&os__slug={os_slug}", opener, - )["objects"][0] + )["objects"] + } + except (urllib.error.URLError, ValueError, KeyError) as e: + print( + f"warning: could not fetch python.org release data for " + f"{platform} ({e}); leaving {', '.join(sorted(tags))} unchanged", + file=sys.stderr, + ) + return revisions, hashes - prefix = f"Python {tag}." - if not release["name"].startswith(prefix): + for tag in sorted(tags): + prefix = f"Python {tag}." + try: + candidates = [ + release for release in releases if release["name"].startswith(prefix) + ] + if not candidates: print( - f"warning: unexpected release name {release['name']!r} for " - f"Python {tag}; leaving unchanged", + f"warning: no releases found for Python {tag}; leaving unchanged", file=sys.stderr, ) continue - revision = release["name"][len(prefix) :] + release = max(candidates, key=lambda release: release["release_date"]) - files = _python_org_api_get( - f"{PYTHON_ORG_API_ROOT}/downloads/release_file/?format=json" - f"&release__slug={slug}&os__slug={os_slug}", - opener, - )["objects"] - if len(files) != 1: + file = files_by_release_uri.get(release["resource_uri"]) + if file is None: print( - f"warning: expected exactly one {platform} release file for " - f"Python {tag} ({slug}), found {len(files)}; leaving unchanged", + f"warning: no {platform} release file found for " + f"{release['name']}; leaving unchanged", file=sys.stderr, ) continue - digest = files[0]["sha256_sum"] - except (urllib.error.URLError, ValueError, KeyError, IndexError) as e: + + revision = release["name"][len(prefix) :] + digest = file["sha256_sum"] + except KeyError as e: print( - f"warning: could not resolve latest release for Python {tag} " + f"warning: malformed python.org data for Python {tag} " f"({e}); leaving unchanged", file=sys.stderr, ) From 7c5d61db2ad8f446e2bc04df0bc919e4668dc8b5 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 22 Sep 2026 16:36:58 +0800 Subject: [PATCH 7/7] Minor cleanups. --- .gitignore | 1 + scripts/update_support.py | 30 ++++++++++++++++++++---------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 93fb9e13..6494bc8b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ venv *.egg-info .kilo .opencode +docs/superpowers/ diff --git a/scripts/update_support.py b/scripts/update_support.py index 94c02a53..2fd31201 100644 --- a/scripts/update_support.py +++ b/scripts/update_support.py @@ -158,22 +158,28 @@ def _official_cpython_support( hashes: dict[str, str] = {} try: releases = _python_org_api_get( - f"{PYTHON_ORG_API_ROOT}/downloads/release/?format=json" - "&version=3&is_published=true", + ( + f"{PYTHON_ORG_API_ROOT}/downloads/release/?format=json" + "&version=3&is_published=true" + ), opener, )["objects"] files_by_release_uri = { file["release"]: file for file in _python_org_api_get( - f"{PYTHON_ORG_API_ROOT}/downloads/release_file/?format=json" - f"&os__slug={os_slug}", + ( + f"{PYTHON_ORG_API_ROOT}/downloads/release_file/?format=json" + f"&os__slug={os_slug}" + ), opener, )["objects"] } except (urllib.error.URLError, ValueError, KeyError) as e: print( - f"warning: could not fetch python.org release data for " - f"{platform} ({e}); leaving {', '.join(sorted(tags))} unchanged", + ( + f"warning: could not fetch python.org release data for " + f"{platform} ({e}); leaving {', '.join(sorted(tags))} unchanged" + ), file=sys.stderr, ) return revisions, hashes @@ -195,8 +201,10 @@ def _official_cpython_support( file = files_by_release_uri.get(release["resource_uri"]) if file is None: print( - f"warning: no {platform} release file found for " - f"{release['name']}; leaving unchanged", + ( + f"warning: no {platform} release file found for " + f"{release['name']}; leaving unchanged" + ), file=sys.stderr, ) continue @@ -205,8 +213,10 @@ def _official_cpython_support( digest = file["sha256_sum"] except KeyError as e: print( - f"warning: malformed python.org data for Python {tag} " - f"({e}); leaving unchanged", + ( + f"warning: malformed python.org data for Python {tag} " + f"({e}); leaving unchanged" + ), file=sys.stderr, ) continue