Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .github/actions/app-create/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 0 additions & 7 deletions .github/workflows/app-build-verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -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 }}
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -376,5 +376,5 @@ jobs:
strategy:
fail-fast: false
matrix:
framework: [ toga, pyside6, pygame, console ]
framework: [ toga, pyside6, console ]
format: [ app, VisualStudio ]
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
venv
.envrc
.vscode
.idea
*.pyc
*.egg-info
.kilo
.opencode
212 changes: 212 additions & 0 deletions scripts/_briefcase_toml.py
Original file line number Diff line number Diff line change
@@ -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.<var> == "<value>" %}` 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.<var> %}` / `{% if not
# cookiecutter.<var> %}` 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 `"<tag>": 'key = "value"',` dict
entry line for `key`."""
return re.compile(
rf'^(?P<indent>\s*)"(?P<tag>{TAG_PATTERN})":\s*'
rf"'{re.escape(key)} = \"(?P<value>[^\"]*)\"',\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.<flag>` 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
82 changes: 82 additions & 0 deletions scripts/_github.py
Original file line number Diff line number Diff line change
@@ -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')}"
)
Loading
Loading