From eed5d6e66d744e7beca14ec5c7b94d471ad94447 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 8 Sep 2026 13:52:59 +0800 Subject: [PATCH 1/2] Add scripts to support updating template and stub hashes. --- .gitignore | 2 + scripts/_briefcase_toml.py | 212 ++++++++++++++++++++++ scripts/_github.py | 82 +++++++++ scripts/_platforms.py | 40 +++++ scripts/update_stub.py | 245 +++++++++++++++++++++++++ scripts/update_support.py | 354 +++++++++++++++++++++++++++++++++++++ 6 files changed, 935 insertions(+) create mode 100644 scripts/_briefcase_toml.py create mode 100644 scripts/_github.py create mode 100644 scripts/_platforms.py create mode 100644 scripts/update_stub.py create mode 100644 scripts/update_support.py diff --git a/.gitignore b/.gitignore index 06778a72..094be65c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ venv .envrc +.vscode .idea *.pyc *.egg-info +.kilo/ diff --git a/scripts/_briefcase_toml.py b/scripts/_briefcase_toml.py new file mode 100644 index 00000000..41222f30 --- /dev/null +++ b/scripts/_briefcase_toml.py @@ -0,0 +1,212 @@ +"""Shared helpers for editing the jinja-templated dict entries in +`{{ cookiecutter.format }}/briefcase.toml`. + +That file isn't valid TOML on its own -- it's a jinja template that, for each +Python version tag, picks a `"key = value"` string out of a literal dict and +splices it into the surrounding TOML. e.g.: + + {{ { + "3.11": 'support_revision = "10"', + "3.12": 'support_revision = "10"', + }.get(cookiecutter.python_version|py_tag, "") }} + +All four Briefcase templates (macOS, iOS, Windows, Linux-flatpak) now use +exactly one normalized entry format: the outer dict-value string is +single-quoted, and the inner `key = value` is always double-quoted, even for +bare numeric revisions (e.g. `'support_revision = "10"'`). These helpers only +read and write that one format -- there is no support for older/legacy +variants (unquoted numeric values, single-quoted inner values, etc.). + +These helpers let update_support.py / update_stub.py find and rewrite those +dict entries, plus the odd scalar entry (e.g. `stub_binary_revision`), +without needing a real jinja/TOML parser. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +TAG_PATTERN = r"\d+\.\d+" + +# Matches a jinja `{% if cookiecutter. == "" %}` line (with an +# optional leading `-` before `%}`), e.g. `{% if cookiecutter.host_arch == +# "AMD64" -%}`. +EQ_IF_RE = re.compile(r'{%-?\s*if\s+cookiecutter\.(\w+)\s*==\s*"([^"]+)"\s*-?%}') + +# Matches a jinja `{% if cookiecutter. %}` / `{% if not +# cookiecutter. %}` boolean-flag line. Note this only matches when +# nothing else appears between the variable name and the closing `%}`, so it +# never accidentally matches an EQ_IF_RE line. +BOOL_IF_RE = re.compile(r"{%-?\s*if\s+(not\s+)?cookiecutter\.(\w+)\s*-?%}") + +ELSE_RE = re.compile(r"{%-?\s*else\s*-?%}") +ENDIF_RE = re.compile(r"{%-?\s*endif\s*-?%}") + + +def read_toml(path: Path) -> str: + return Path(path).read_text() + + +def write_toml(path: Path, text: str) -> None: + Path(path).write_text(text) + + +def entry_regex(key: str) -> re.Pattern[str]: + """Build a regex matching one normalized `"": 'key = "value"',` dict + entry line for `key`.""" + return re.compile( + rf'^(?P\s*)"(?P{TAG_PATTERN})":\s*' + rf"'{re.escape(key)} = \"(?P[^\"]*)\"',\s*$" + ) + + +def render_entry(indent: str, tag: str, key: str, value: str) -> str: + """Render one normalized dict entry line.""" + return f'{indent}"{tag}": \'{key} = "{value}"\',\n' + + +def tags_present(lines: list[str], *patterns: re.Pattern[str]) -> set[str]: + """Return the set of Python version tags with an entry matching any of + `patterns`, anywhere in `lines`.""" + tags: set[str] = set() + for line in lines: + for pattern in patterns: + match = pattern.match(line) + if match: + tags.add(match.group("tag")) + return tags + + +def apply_updates( + lines: list[str], + pattern: re.Pattern[str], + key: str, + values: dict[str, str | None], + line_range: range | None = None, +) -> set[int]: + """Update every line matching `pattern` whose tag is a key in `values`. + + `lines` is mutated in place. `values[tag]` of None means: this entry + should be removed entirely (e.g. no matching release asset was found for + that tag). The set of line indices that should be dropped is returned; + the caller is responsible for actually removing them (via `render`), so + that indices computed by other callers/passes stay valid. + + If `line_range` is given, only lines within it are considered -- this is + needed when the same `pattern`/`key` occurs in more than one place in the + file (e.g. per-architecture hash dicts). + """ + to_delete: set[int] = set() + indices = line_range if line_range is not None else range(len(lines)) + for i in indices: + match = pattern.match(lines[i]) + if not match or match.group("tag") not in values: + continue + tag = match.group("tag") + value = values[tag] + if value is None: + to_delete.add(i) + continue + lines[i] = render_entry(match.group("indent"), tag, key, value) + return to_delete + + +def render(lines: list[str], to_delete: set[int]) -> str: + return "".join(line for i, line in enumerate(lines) if i not in to_delete) + + +def update_scalar(text: str, key: str, value: str) -> str: + """Update a plain (non-per-tag) `key = "value"` scalar entry, e.g. + `stub_binary_revision = "16"`. Raises ValueError if `key` isn't found.""" + pattern = re.compile(rf'({re.escape(key)} = ")[^"]*(")') + new_text, count = pattern.subn(rf"\g<1>{value}\g<2>", text, count=1) + if count == 0: + raise ValueError(f"Could not find {key} in briefcase.toml") + return new_text + + +def scalar_present(text: str, key: str) -> bool: + """True if a plain `key = "value"` scalar entry is present anywhere in + `text`.""" + return re.search(rf'{re.escape(key)} = "[^"]*"', text) is not None + + +def find_conditional_blocks( + lines: list[str], + *flags: str, + arch_alternatives: dict[str, dict[str, str]] | None = None, +) -> dict[tuple, tuple[int, int]]: + """Walk the jinja `{% if %}` / `{% else %}` / `{% endif %}` structure of + `lines`, and find the (start, end) line range (inclusive, 0-indexed) of + every line that falls under a particular combination of `flags` values. + + `flags` names the `cookiecutter.` variables of interest. Each may + be either a boolean flag (`{% if cookiecutter.x %}` / `{% if not + cookiecutter.x %}`) or a string-equality flag (`{% if cookiecutter.x == + "VALUE" %}`) -- both are tracked automatically as the file is walked. + + For boolean flags, the `{% else %}` branch's value is simply the + logical negation. String-equality flags have no such generic inverse, so + `arch_alternatives` must supply one: `{flag_name: {value: other_value}}`, + e.g. `{"host_arch": {"AMD64": "ARM64", "ARM64": "AMD64"}}`. + + The returned dict is keyed by a tuple of values in the same order as + `flags`; only combinations that actually appear as *leaf* branches (i.e. + every requested flag has a known value in that branch) are included. + """ + arch_alternatives = arch_alternatives or {} + stack: list[list] = [] # each entry: [var_name, current_value] + blocks: dict[tuple, tuple[int, int]] = {} + + def current_state() -> dict[str, object]: + state: dict[str, object] = {} + for var_name, value in stack: + state[var_name] = value + return state + + for i, line in enumerate(lines): + eq_match = EQ_IF_RE.search(line) + if eq_match: + stack.append([eq_match.group(1), eq_match.group(2)]) + continue + + bool_match = BOOL_IF_RE.search(line) + if bool_match: + negate = bool(bool_match.group(1)) + stack.append([bool_match.group(2), not negate]) + continue + + if ELSE_RE.search(line): + if stack: + var_name, value = stack[-1] + if isinstance(value, bool): + stack[-1][1] = not value + else: + alternatives = arch_alternatives.get(var_name, {}) + if value not in alternatives: + raise ValueError( + f"No alternative value known for " + f"{var_name}={value!r}; pass it via " + "arch_alternatives" + ) + stack[-1][1] = alternatives[value] + continue + + if ENDIF_RE.search(line): + if stack: + stack.pop() + continue + + state = current_state() + if not all(flag in state for flag in flags): + continue + + key = tuple(state[flag] for flag in flags) + if key not in blocks: + blocks[key] = (i, i) + else: + start, _ = blocks[key] + blocks[key] = (start, i) + + return blocks diff --git a/scripts/_github.py b/scripts/_github.py new file mode 100644 index 00000000..d821ae4b --- /dev/null +++ b/scripts/_github.py @@ -0,0 +1,82 @@ +"""Shared helpers for querying the GitHub releases API. + +Used by update_support.py and update_stub.py to find the latest published +release of an upstream repository (or every release, when a per-Python-tag +search across release history is needed), and to read the sha256 digest of a +release asset without having to download it. +""" + +from __future__ import annotations + +import json +import os +import urllib.request +from typing import Callable + +API_ROOT = "https://api.github.com" + +# An "opener" has the same shape as urllib.request.urlopen: given a Request +# (or URL string), it returns a context-manager-able response object with a +# .read() method. Tests substitute a fake opener here instead of hitting the +# network. +Opener = Callable[[urllib.request.Request], object] + + +def api_get(url: str, opener: Opener = urllib.request.urlopen) -> object: + """Perform a GET request against the GitHub API and return the parsed JSON body. + + A GitHub personal access token can be provided via the `GITHUB_TOKEN` + environment variable to avoid unauthenticated API rate limits. + """ + request = urllib.request.Request( + url, headers={"Accept": "application/vnd.github+json"} + ) + token = os.environ.get("GITHUB_TOKEN") + if token: + request.add_header("Authorization", f"Bearer {token}") + with opener(request) as response: + return json.load(response) + + +def fetch_all_releases( + repo: str, + opener: Opener = urllib.request.urlopen, +) -> list[dict]: + """Fetch every release of `repo` (e.g. "beeware/Python-Apple-support").""" + releases = [] + page = 1 + while True: + batch = api_get( + f"{API_ROOT}/repos/{repo}/releases?per_page=100&page={page}", + opener=opener, + ) + if not batch: + break + releases.extend(batch) + if len(batch) < 100: + break + page += 1 + return releases + + +def fetch_latest_release(repo: str, opener: Opener = urllib.request.urlopen) -> dict: + """Fetch the single most recent release of `repo`.""" + return api_get(f"{API_ROOT}/repos/{repo}/releases/latest", opener=opener) + + +def asset_digest(release: dict, asset_name: str) -> str: + """Return the sha256 digest of `asset_name` in `release`, as reported by + the GitHub API (the asset is never downloaded).""" + for asset in release.get("assets", []): + if asset["name"] != asset_name: + continue + digest = asset.get("digest") + if not digest: + raise ValueError( + f"Asset {asset_name} has no digest reported by the GitHub API" + ) + return digest + + raise ValueError( + f"Could not find asset {asset_name} in release {release.get('tag_name')}" + ) diff --git a/scripts/_platforms.py b/scripts/_platforms.py new file mode 100644 index 00000000..092dcbd0 --- /dev/null +++ b/scripts/_platforms.py @@ -0,0 +1,40 @@ +"""Platform detection for Briefcase template directories. + +Each of the Briefcase template repos this tooling supports has an +unambiguous platform name in its directory name: + + briefcase-iOS-Xcode-template -> iOS + briefcase-linux-appimage-template -> linux + briefcase-linux-flatpak-template -> linux + briefcase-macOS-app-template -> macOS + briefcase-macOS-Xcode-template -> macOS + briefcase-windows-app-template -> windows + briefcase-windows-VisualStudio-template -> windows +""" + +from __future__ import annotations + +from pathlib import Path + +# Ordered (substring, platform) pairs; checked in order against the +# lower-cased directory basename. +_PLATFORM_SUBSTRINGS = ["macOS", "iOS", "windows", "linux"] + + +def detect_platform(template_dir: Path) -> str: + """Infer the platform from a template directory's basename.""" + name = Path(template_dir).name.lower() + for platform in _PLATFORM_SUBSTRINGS: + if platform.lower() in name.lower(): + return platform + raise ValueError( + f"Could not detect platform from template directory {str(template_dir)!r}; " + "expected the directory name to contain one of: macos, ios, windows, linux" + ) + + +def briefcase_toml_path(template_dir: Path) -> Path: + """Path to the (unrendered) `briefcase.toml` jinja template inside a + template directory. The `{{ cookiecutter.format }}` directory name is + literal/unrendered in all four templates.""" + return Path(template_dir) / "{{ cookiecutter.format }}" / "briefcase.toml" diff --git a/scripts/update_stub.py b/scripts/update_stub.py new file mode 100644 index 00000000..d1940866 --- /dev/null +++ b/scripts/update_stub.py @@ -0,0 +1,245 @@ +"""Update the stub binary revision and hash entries in briefcase.toml. + +Usage:: + + python scripts/update_stub.py + +`` is the path to one of the four Briefcase template repo +checkouts. The platform (macOS / Windows) is inferred from the directory's +name (see platforms.py): + +- macOS: the latest GitHub release of `beeware/briefcase-macOS-Xcode-template` + (tag `b`), which publishes the stub binaries as release assets + named `-Stub--b.zip`, one for each + combination of framework/non-framework (`use_framework`) and console/GUI + app (`console_app`). +- Windows: the latest GitHub release of + `beeware/briefcase-windows-VisualStudio-template` (tag `b`), + which publishes assets named + `-Stub---b.zip`, one for + each combination of host architecture (`host_arch`) and console/GUI app + (`console_app`). + +Neither iOS nor Linux (flatpak) templates have a `stub_binary_revision` / +`stub_binary_hash` structure in their `briefcase.toml` at all, so running +this script against one of those template directories fails with the same +"Could not find stub_binary_revision in briefcase.toml" error it would raise +for any other briefcase.toml that's missing that structure -- there is no +separate platform allow-list. + +For every Python tag already listed in a `stub_binary_hash` dict, if a +matching upstream asset is found its hash is updated; if not, that tag's +entry is removed entirely (a missing stub binary means that variant isn't +available for that Python version yet, unlike a missing support package, +which is left unchanged instead). + +A GitHub personal access token can be provided via the `GITHUB_TOKEN` +environment variable to raise the GitHub API's unauthenticated rate limit. +""" + +from __future__ import annotations + +import re +import sys +import urllib.request +from pathlib import Path +from typing import Callable, NamedTuple + +from _briefcase_toml import ( + entry_regex, + find_conditional_blocks, + read_toml, + render, + render_entry, + scalar_present, + update_scalar, + write_toml, +) +from _github import fetch_latest_release +from _platforms import briefcase_toml_path, detect_platform + +HASH_KEY = "stub_binary_hash" +REVISION_KEY = "stub_binary_revision" +HASH_ENTRY_RE = entry_regex(HASH_KEY) + +# Matches release tags of the form "b", e.g. "b16". +_TAG_RE = re.compile(r"^b(?P\d+)$") + + +class StubSource(NamedTuple): + repo: str + block_flags: tuple[str, str] + arch_alternatives: dict[str, dict[str, str]] | None + parse_variants: Callable[[dict], dict[tuple, dict[str, str]]] + variant_name: Callable[[tuple], str] + + +# --- macOS: beeware/briefcase-macOS-Xcode-template -------------------------- + +# Matches an asset name like "Console-LStub-3.13-b16.zip" or "GUI-Stub-3.14-b16.zip". +_MACOS_ASSET_RE = re.compile( + r"^(?PConsole|GUI)-(?PL?)Stub-" + r"(?P\d+\.\d+)-b(?P\d+)\.zip$" +) + + +def _macOS_variants(release: dict) -> dict[tuple[bool, bool], dict[str, str]]: + """Map (use_framework, console_app) -> {python_tag: digest}.""" + variants: dict[tuple[bool, bool], dict[str, str]] = {} + for asset in release.get("assets", []): + match = _MACOS_ASSET_RE.match(asset["name"]) + if not match: + continue + + use_framework = match.group("stub_prefix") == "" + console_app = match.group("app_prefix") == "Console" + python_tag = match.group("py_tag") + + digest = asset.get("digest") + if not digest: + raise ValueError( + f"Asset {asset['name']} has no digest reported by the GitHub API" + ) + variants.setdefault((use_framework, console_app), {})[python_tag] = digest + return variants + + +def _macos_variant_name(key: tuple[bool, bool]) -> str: + use_framework, console_app = key + return ( + f"{'framework' if use_framework else 'non-framework'}, " + f"{'console' if console_app else 'GUI'}" + ) + + +# --- Windows: beeware/briefcase-windows-VisualStudio-template --------------- + +# Matches an asset name like "Console-Stub-3.11-amd64-b13.zip" or +# "GUI-Stub-3.14-arm64-b13.zip". +_WINDOWS_ASSET_RE = re.compile( + r"^(?PConsole|GUI)-Stub-(?P\d+\.\d+)-" + r"(?Pamd64|arm64)-b(?P\d+)\.zip$" +) + +_WINDOWS_ARCH_ALTERNATIVES = {"host_arch": {"AMD64": "ARM64", "ARM64": "AMD64"}} + + +def _windows_variants(release: dict) -> dict[tuple[str, bool], dict[str, str]]: + """Map (host_arch, console_app) -> {python_tag: digest}.""" + variants: dict[tuple[str, bool], dict[str, str]] = {} + for asset in release.get("assets", []): + match = _WINDOWS_ASSET_RE.match(asset["name"]) + if not match: + continue + + arch = match.group("arch").upper() + console_app = match.group("app_prefix") == "Console" + python_tag = match.group("py_tag") + + digest = asset.get("digest") + if not digest: + raise ValueError( + f"Asset {asset['name']} has no digest reported by the GitHub API" + ) + variants.setdefault((arch, console_app), {})[python_tag] = digest + return variants + + +def _windows_variant_name(key: tuple[str, bool]) -> str: + arch, console_app = key + return f"{arch}, {'console' if console_app else 'GUI'}" + + +STUB_SOURCES: dict[str, StubSource] = { + "macOS": StubSource( + repo="beeware/briefcase-macOS-Xcode-template", + block_flags=("use_framework", "console_app"), + arch_alternatives=None, + parse_variants=_macOS_variants, + variant_name=_macos_variant_name, + ), + "windows": StubSource( + repo="beeware/briefcase-windows-VisualStudio-template", + block_flags=("host_arch", "console_app"), + arch_alternatives=_WINDOWS_ARCH_ALTERNATIVES, + parse_variants=_windows_variants, + variant_name=_windows_variant_name, + ), +} + + +def _extract_revision(release: dict, repo: str) -> str: + match = _TAG_RE.match(release["tag_name"]) + if not match: + raise ValueError( + f"Latest release tag {release['tag_name']!r} of {repo} doesn't " + "look like a stub binary revision (expected 'b')" + ) + return match.group("revision") + + +def update(template_dir: Path, opener=urllib.request.urlopen) -> None: + platform = detect_platform(template_dir) + toml_path = briefcase_toml_path(template_dir) + text = read_toml(toml_path) + + # Platforms with no stub binary structure at all (iOS, linux-flatpak) + # naturally fail here with the same error a real mismatch would produce + # -- there is no separate "unsupported platform" check. + if not scalar_present(text, REVISION_KEY): + raise ValueError(f"Could not find {REVISION_KEY} in briefcase.toml") + + try: + source = STUB_SOURCES[platform] + except KeyError: + raise ValueError(f"Don't know how to resolve stub binaries for {platform}") + + release = fetch_latest_release(source.repo, opener=opener) + revision = _extract_revision(release, source.repo) + variants = source.parse_variants(release) + + print(f"Updating to revision {revision}") + text = update_scalar(text, REVISION_KEY, revision) + lines = text.splitlines(keepends=True) + + blocks = find_conditional_blocks( + lines, *source.block_flags, arch_alternatives=source.arch_alternatives + ) + + to_delete: set[int] = set() + for key, (start, end) in blocks.items(): + values = variants.get(key, {}) + variant_name = source.variant_name(key) + + for i in range(start, end + 1): + match = HASH_ENTRY_RE.match(lines[i]) + if not match: + continue + + python_tag = match.group("tag") + digest = values.get(python_tag) + + if digest is None: + print(f"{variant_name} {python_tag}: no asset found; removing entry") + to_delete.add(i) + else: + print(f"{variant_name} {python_tag}: {digest}") + lines[i] = render_entry( + match.group("indent"), python_tag, HASH_KEY, digest + ) + + write_toml(toml_path, render(lines, to_delete)) + + +def main(argv: list[str]) -> int: + if len(argv) != 2: + print(f"usage: {argv[0]} ", file=sys.stderr) + return 2 + + update(Path(argv[1])) + print("Updated stub binary revision and hash.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/update_support.py b/scripts/update_support.py new file mode 100644 index 00000000..e6a3f03a --- /dev/null +++ b/scripts/update_support.py @@ -0,0 +1,354 @@ +"""Update the support package revision and hash entries in briefcase.toml. + +Usage:: + + python scripts/update_support.py + +`` is the path to one of the four Briefcase template repo +checkouts (e.g. `~/beeware/templates/briefcase-macOS-app-template`). The +platform (macOS / iOS / windows / linux) is inferred from the directory's name +(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`). +- Windows: the Windows embeddable-package index published at + https://www.python.org/ftp/python/index-windows.json, per AMD64/ARM64 host + architecture. +- Linux: the latest GitHub release of + `astral-sh/python-build-standalone`, per x86_64/aarch64 host architecture. + +For every Python major.minor tag already listed in briefcase.toml's +`support_revision` / `support_package_hash` entries, this looks up the matching +upstream revision/hash and rewrites the entry in the normalized format (see +briefcase_toml.py). Tags not already present in briefcase.toml are never added. +If no matching upstream data is found for an already-listed tag, that tag is +left unchanged and a warning is printed to stderr. + +A GitHub personal access token can be provided via the `GITHUB_TOKEN` +environment variable to raise the GitHub API's unauthenticated rate limit. +""" + +from __future__ import annotations + +import json +import re +import sys +import urllib.request +from pathlib import Path + +from _briefcase_toml import ( + apply_updates, + entry_regex, + find_conditional_blocks, + read_toml, + render, + tags_present, + write_toml, +) +from _github import asset_digest, fetch_all_releases, fetch_latest_release +from _platforms import briefcase_toml_path, detect_platform + +REVISION_KEY = "support_revision" +HASH_KEY = "support_package_hash" + +REVISION_ENTRY_RE = entry_regex(REVISION_KEY) +HASH_ENTRY_RE = entry_regex(HASH_KEY) + +# --- macOS / iOS: beeware/Python-Apple-support ------------------------------ + +APPLE_SUPPORT_REPO = "beeware/Python-Apple-support" + +# Matches release tags of the form "-b", e.g. "3.14-b11". +APPLE_TAG_RE = re.compile(r"^(?P\d+\.\d+)-b(?P\d+)$") + + +def _apple_support( + platform: str, + tags: set[str], + opener, +) -> tuple[dict[str, str], dict[str, str]]: + """Flat (no per-architecture split) revisions/hashes for macOS and iOS.""" + releases = fetch_all_releases(APPLE_SUPPORT_REPO, opener=opener) + + latest: dict[str, tuple[int, dict]] = {} + for release in releases: + match = APPLE_TAG_RE.match(release["tag_name"]) + if not match: + continue + py_version = match.group("py_version") + revision = int(match.group("revision")) + if py_version not in latest or revision > latest[py_version][0]: + latest[py_version] = (revision, release) + + revisions: dict[str, str] = {} + hashes: dict[str, str] = {} + for tag in sorted(tags): + if tag not in latest: + print( + f"warning: no releases found for Python {tag}; leaving unchanged", + file=sys.stderr, + ) + continue + revision, release = latest[tag] + expected_name = f"Python-{tag}-{platform}-support.b{revision}.tar.gz" + digest = asset_digest(release, expected_name) + revisions[tag] = str(revision) + hashes[tag] = digest + print(f"{tag}: support_revision = {revision}, {digest}") + return revisions, hashes + + +# --- Windows: python.org embeddable-package index ---------------------------- + +WINDOWS_INDEX_URL = "https://www.python.org/ftp/python/index-windows.json" + +# "pythonembed--" suffix -> host_arch value. +WINDOWS_ARCH_FOR_SUFFIX = {"64": "AMD64", "arm64": "ARM64"} + +WINDOWS_ID_RE = re.compile(r"^pythonembed-(?P\d+\.\d+)-(?P64|arm64)$") +WINDOWS_VERSION_RE = re.compile( + r"^(?P\d+)\.(?P\d+)\.(?P\d+)" + r"(?:(?P
a|b|rc)(?P\d+))?$"
+)
+
+WINDOWS_ARCH_ALTERNATIVES = {"host_arch": {"AMD64": "ARM64", "ARM64": "AMD64"}}
+
+
+def _windows_version_sort_key(
+    version: str,
+) -> tuple[int, int, int, int, int] | None:
+    """Sortable key for a CPython version string such as "3.13.9" or
+    "3.15.0rc2". Final releases sort higher than pre-releases (alpha < beta
+    < rc < final) for the same major.minor.micro. None if unparseable."""
+    match = WINDOWS_VERSION_RE.match(version)
+    if not match:
+        return None
+    pre_rank = {"a": 0, "b": 1, "rc": 2, None: 3}[match.group("pre")]
+    preno = int(match.group("preno")) if match.group("preno") else 0
+    return (
+        int(match.group("major")),
+        int(match.group("minor")),
+        int(match.group("micro")),
+        pre_rank,
+        preno,
+    )
+
+
+def _windows_revision_for(version: str) -> str:
+    """The support revision component of a version string; e.g. "9" for
+    "3.13.9", or "0rc2" for "3.15.0rc2"."""
+    parts = version.split(".", 2)
+    return parts[2] if len(parts) > 2 else "0"
+
+
+def _windows_highest_versions(opener) -> dict[str, dict[str, tuple[str, str]]]:
+    """Return the highest available micro version (and its hash) for every
+    major.minor/host_arch combination found in the Windows embeddable
+    package index. Result: {python_tag: {host_arch: (version, "algo:hexdigest")}}."""
+    request = urllib.request.Request(WINDOWS_INDEX_URL)
+    with opener(request) as response:
+        index = json.load(response)
+
+    best: dict[tuple[str, str], tuple[tuple, str, str]] = {}
+    for entry in index["versions"]:
+        match = WINDOWS_ID_RE.match(entry["id"])
+        if not match:
+            continue
+
+        tag = match.group("tag")
+        arch = WINDOWS_ARCH_FOR_SUFFIX[match.group("suffix")]
+        version = entry["sort-version"]
+
+        key = _windows_version_sort_key(version)
+        if key is None:
+            print(
+                f"  -> skipping unparseable version {version!r} for {entry['id']}",
+                file=sys.stderr,
+            )
+            continue
+
+        algo, digest = next(iter(entry["hash"].items()))
+        hash_str = f"{algo}:{digest}"
+
+        current = best.get((tag, arch))
+        if current is None or key > current[0]:
+            best[(tag, arch)] = (key, version, hash_str)
+
+    result: dict[str, dict[str, tuple[str, str]]] = {}
+    for (tag, arch), (_, version, hash_str) in best.items():
+        result.setdefault(tag, {})[arch] = (version, hash_str)
+    return result
+
+
+def _windows_support(
+    tags: set[str], opener
+) -> tuple[dict[str, str], dict[str, dict[str, str]]]:
+    """Flat revisions, plus per-architecture (AMD64/ARM64) hashes."""
+    highest = _windows_highest_versions(opener)
+
+    revisions: dict[str, str] = {}
+    hashes_by_arch: dict[str, dict[str, str]] = {"AMD64": {}, "ARM64": {}}
+
+    for tag in sorted(tags):
+        variants = highest.get(tag)
+        if not variants:
+            print(
+                f"warning: no index data found for Python {tag}; leaving unchanged",
+                file=sys.stderr,
+            )
+            continue
+
+        versions = {version for version, _ in variants.values()}
+        if len(versions) > 1:
+            print(
+                f"warning: Python {tag} has differing highest versions "
+                f"across architectures: {sorted(versions)}",
+                file=sys.stderr,
+            )
+        version = next(iter(versions))
+        revisions[tag] = _windows_revision_for(version)
+
+        for arch, (_, hash_str) in variants.items():
+            hashes_by_arch[arch][tag] = hash_str
+            print(f"{tag} ({arch}): support_package_hash -> {hash_str} ({version})")
+
+    return revisions, hashes_by_arch
+
+
+# --- Linux: astral-sh/python-build-standalone ---------------------
+
+PYTHON_BUILD_STANDALONE_REPO = "astral-sh/python-build-standalone"
+
+LINUX_ARCH_TRIPLE = {
+    "x86_64": "x86_64-unknown-linux-gnu",
+    "aarch64": "aarch64-unknown-linux-gnu",
+}
+
+LINUX_ARCH_ALTERNATIVES = {"host_arch": {"x86_64": "aarch64", "aarch64": "x86_64"}}
+
+
+def _linux_support(
+    tags: set[str], opener
+) -> tuple[dict[str, str], dict[str, dict[str, str]]]:
+    """Flat revisions (full version string), plus per-architecture
+    (x86_64/aarch64) hashes."""
+    release = fetch_latest_release(PYTHON_BUILD_STANDALONE_REPO, opener=opener)
+    print(f"Latest release of {PYTHON_BUILD_STANDALONE_REPO}: {release['tag_name']}")
+
+    revisions: dict[str, str] = {}
+    hashes_by_arch: dict[str, dict[str, str]] = {arch: {} for arch in LINUX_ARCH_TRIPLE}
+
+    for tag in sorted(tags):
+        versions_found: set[str] = set()
+        for arch, triple in LINUX_ARCH_TRIPLE.items():
+            pattern = re.compile(
+                rf"^cpython-(?P{re.escape(tag)}\.[^-]+)-{re.escape(triple)}"
+                rf"-install_only_stripped\.tar\.gz$"
+            )
+            for asset in release.get("assets", []):
+                match = pattern.match(asset["name"])
+                if not match:
+                    continue
+                digest = asset.get("digest")
+                if not digest:
+                    raise ValueError(
+                        f"Asset {asset['name']} has no digest reported by "
+                        "the GitHub API"
+                    )
+                hashes_by_arch[arch][tag] = digest
+                version = match.group("version")
+                versions_found.add(version)
+                print(f"{tag} ({arch}): support_package_hash -> {digest} ({version})")
+                break
+            else:
+                print(
+                    f"  -> no {triple} asset found for Python {tag} "
+                    f"in release {release['tag_name']}",
+                    file=sys.stderr,
+                )
+
+        if not versions_found:
+            print(
+                f"warning: no release data found for Python {tag}; leaving unchanged",
+                file=sys.stderr,
+            )
+            continue
+        if len(versions_found) > 1:
+            print(
+                f"warning: Python {tag} has differing versions across "
+                f"architectures: {sorted(versions_found)}",
+                file=sys.stderr,
+            )
+        revisions[tag] = next(iter(versions_found))
+
+    return revisions, hashes_by_arch
+
+
+# --- Dispatch -----------------------------------------------------------------
+
+
+def update(template_dir: Path, opener=urllib.request.urlopen) -> None:
+    platform = detect_platform(template_dir)
+    toml_path = briefcase_toml_path(template_dir)
+    text = read_toml(toml_path)
+    lines = text.splitlines(keepends=True)
+
+    tags = tags_present(lines, REVISION_ENTRY_RE, HASH_ENTRY_RE)
+
+    to_delete: set[int] = set()
+
+    if platform in {"macOS", "iOS"}:
+        revisions, hashes = _apple_support(platform, tags, opener)
+        to_delete |= apply_updates(lines, REVISION_ENTRY_RE, REVISION_KEY, revisions)
+        to_delete |= apply_updates(lines, HASH_ENTRY_RE, HASH_KEY, hashes)
+
+    elif platform == "windows":
+        revisions, hashes_by_arch = _windows_support(tags, opener)
+        to_delete |= apply_updates(lines, REVISION_ENTRY_RE, REVISION_KEY, revisions)
+        blocks = find_conditional_blocks(
+            lines, "host_arch", arch_alternatives=WINDOWS_ARCH_ALTERNATIVES
+        )
+        for (arch,), (start, end) in blocks.items():
+            values = hashes_by_arch.get(arch, {})
+            to_delete |= apply_updates(
+                lines,
+                HASH_ENTRY_RE,
+                HASH_KEY,
+                values,
+                line_range=range(start, end + 1),
+            )
+
+    elif platform == "linux":
+        revisions, hashes_by_arch = _linux_support(tags, opener)
+        to_delete |= apply_updates(lines, REVISION_ENTRY_RE, REVISION_KEY, revisions)
+        blocks = find_conditional_blocks(
+            lines, "host_arch", arch_alternatives=LINUX_ARCH_ALTERNATIVES
+        )
+        for (arch,), (start, end) in blocks.items():
+            values = hashes_by_arch.get(arch, {})
+            to_delete |= apply_updates(
+                lines,
+                HASH_ENTRY_RE,
+                HASH_KEY,
+                values,
+                line_range=range(start, end + 1),
+            )
+
+    else:
+        raise ValueError(f"Unsupported platform for update_support.py: {platform!r}")
+
+    write_toml(toml_path, render(lines, to_delete))
+
+
+def main(argv: list[str]) -> int:
+    if len(argv) != 2:
+        print(f"usage: {argv[0]} ", file=sys.stderr)
+        return 2
+
+    update(Path(argv[1]))
+    print("Updated support package revision and hash.")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main(sys.argv))

From 687e9feb42bc10b16eac1cad6f743bbba7e4d567 Mon Sep 17 00:00:00 2001
From: Russell Keith-Magee 
Date: Wed, 9 Sep 2026 11:35:18 +0800
Subject: [PATCH 2/2] Remove PyGame references.

---
 .github/actions/app-create/action.yml  |  1 -
 .github/workflows/app-build-verify.yml |  7 -------
 .github/workflows/ci.yml               | 10 +++++-----
 .gitignore                             |  3 ++-
 4 files changed, 7 insertions(+), 14 deletions(-)

diff --git a/.github/actions/app-create/action.yml b/.github/actions/app-create/action.yml
index 255d0f24..666d3bed 100644
--- a/.github/actions/app-create/action.yml
+++ b/.github/actions/app-create/action.yml
@@ -80,7 +80,6 @@ runs:
         case "$(tr '[:upper:]' '[:lower:]' <<< "${INPUT_FRAMEWORK}")" in
           toga    ) BOOTSTRAP=Toga ;;
           pyside6 ) BOOTSTRAP=PySide6 ;;
-          pygame  ) BOOTSTRAP=Pygame ;;
           console ) BOOTSTRAP=Console ;;
           *       ) BOOTSTRAP="${INPUT_FRAMEWORK}" ;;
         esac
diff --git a/.github/workflows/app-build-verify.yml b/.github/workflows/app-build-verify.yml
index bee26b8f..fcf0692e 100644
--- a/.github/workflows/app-build-verify.yml
+++ b/.github/workflows/app-build-verify.yml
@@ -74,7 +74,6 @@ jobs:
       contents: read
     env:
       PYSIDE6_SYSTEM_REQUIRES: libgl1 libqt6dbus6 libqt6gui6t64 libxcb-cursor0
-      SDL_AUDIODRIVER: dummy  # disable audio for SDL
       TOGA_SYSTEM_REQUIRES: libcairo2-dev libcanberra-gtk3-module libegl1 libgirepository1.0-dev libgirepository-2.0-dev libthai-dev gir1.2-gtk-3.0
     steps:
 
@@ -207,8 +206,6 @@ jobs:
         && contains(fromJSON('["", "app"]'), inputs.target-format)
       working-directory: ${{ steps.create.outputs.project-path }}
       env:
-        # SDL cannot always find hardware acceleration on macOS in CI
-        SDL_VIDEODRIVER: ${{ startsWith(inputs.framework, 'pygame') && 'dummy' || '' }}
         CREATE_OPTIONS: ${{ steps.output-format.outputs.template-override }} ${{ inputs.create-options }}
       run: |
         briefcase create macOS app ${CREATE_OPTIONS} ${BRIEFCASE_VERBOSITY}
@@ -226,8 +223,6 @@ jobs:
         && contains(fromJSON('["", "Xcode"]'), inputs.target-format)
       working-directory: ${{ steps.create.outputs.project-path }}
       env:
-        # SDL cannot always find hardware acceleration on macOS in CI
-        SDL_VIDEODRIVER: ${{ startsWith(inputs.framework, 'pygame') && 'dummy' || '' }}
         CREATE_OPTIONS: ${{ steps.output-format.outputs.template-override }} ${{ inputs.create-options }}
       run: |
         briefcase create macOS Xcode ${CREATE_OPTIONS} ${BRIEFCASE_VERBOSITY}
@@ -339,11 +334,9 @@ jobs:
 
     - name: Build Linux System Project (Arch, Dockerized)
       # Arch is not officially supported on ARM
-      # Arch can't build PyGame apps until they publish 3.14 binary wheels
       if: >
         startsWith(inputs.runner-os, 'ubuntu')
         && !endsWith(inputs.runner-os, '-arm')
-        && !startsWith(inputs.framework, 'pygame')
         && contains(fromJSON('["", "Linux"]'), inputs.target-platform)
         && contains(fromJSON('["", "system"]'), inputs.target-format)
       working-directory: ${{ steps.create.outputs.project-path }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0a74ddb5..c9ef4b1b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -290,7 +290,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        framework: [ toga, pyside6, pygame, console ]
+        framework: [ toga, pyside6, console ]
         runner-os: [ ubuntu-24.04, ubuntu-24.04-arm ]
 
 # see app-build-verify.yml; AppImage testing was disabled sept 2024
@@ -308,7 +308,7 @@ jobs:
 #      fail-fast: false
 #      matrix:
 #        # 2024-07-11 (beeware/briefcase#1908): pyside6 segfaults on AppImage start.
-#        framework: [ toga, pygame, console ]
+#        framework: [ toga, console ]
 
   test-app-build-verify-linux-flatpak:
     name: App Build Verify (Flatpak template)
@@ -323,7 +323,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        framework: [ toga, pyside6, pygame, console ]
+        framework: [ toga, pyside6, console ]
         runner-os: [ ubuntu-24.04, ubuntu-24.04-arm ]
 
         exclude:
@@ -345,7 +345,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        framework: [ toga, pyside6, pygame, console ]
+        framework: [ toga, pyside6, console ]
         format: [ app, Xcode ]
 
   test-app-build-verify-web:
@@ -376,5 +376,5 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        framework: [ toga, pyside6, pygame, console ]
+        framework: [ toga, pyside6, console ]
         format: [ app, VisualStudio ]
diff --git a/.gitignore b/.gitignore
index 094be65c..93fb9e13 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,4 +4,5 @@ venv
 .idea
 *.pyc
 *.egg-info
-.kilo/
+.kilo
+.opencode