# Released under the MIT License. See LICENSE for details.
#
"""A nice collection of ready-to-use pcommands for this package."""
from __future__ import annotations # Docs-generation hack.
# Note: import as little as possible here at the module level to
# keep launch times fast for small snippets.
import sys
from typing import TYPE_CHECKING
from efrotools import pcommand
if TYPE_CHECKING:
from libcst import BaseExpression
from libcst.metadata import CodeRange
[docs]
def compose_docker_gui_release() -> None:
"""Build the docker image with bombsquad cmake gui."""
import batools.docker
batools.docker.docker_compose(headless_build=False)
[docs]
def compose_docker_gui_debug() -> None:
"""Build the docker image with bombsquad debug cmake gui."""
import batools.docker
batools.docker.docker_compose(headless_build=False, build_type='Debug')
[docs]
def compose_docker_server_release() -> None:
"""Build the docker image with bombsquad cmake server."""
import batools.docker
batools.docker.docker_compose()
[docs]
def compose_docker_server_debug() -> None:
"""Build the docker image with bombsquad debug cmake server."""
import batools.docker
batools.docker.docker_compose(build_type='Debug')
[docs]
def compose_docker_arm64_gui_release() -> None:
"""Build the docker image with bombsquad cmake for arm64."""
import batools.docker
batools.docker.docker_compose(headless_build=False, platform='linux/arm64')
[docs]
def compose_docker_arm64_gui_debug() -> None:
"""Build the docker image with bombsquad cmake for arm64."""
import batools.docker
batools.docker.docker_compose(
headless_build=False, platform='linux/arm64', build_type='Debug'
)
[docs]
def compose_docker_arm64_server_release() -> None:
"""Build the docker image with bombsquad cmake server for arm64."""
import batools.docker
batools.docker.docker_compose(platform='linux/arm64')
[docs]
def compose_docker_arm64_server_debug() -> None:
"""Build the docker image with bombsquad cmake server for arm64."""
import batools.docker
batools.docker.docker_compose(platform='linux/arm64', build_type='Debug')
[docs]
def save_docker_images() -> None:
"""Saves bombsquad images loaded into docker."""
import batools.docker
batools.docker.docker_save_images()
[docs]
def remove_docker_images() -> None:
"""Remove the bombsquad images loaded in docker."""
import batools.docker
batools.docker.docker_remove_images()
[docs]
def generate_flatpak_build_env() -> None:
"""Regenerate the offline Python build env used by flatpak builds.
Rewrites pconfig/requirements_build_lock.txt and
pconfig/flatpak/python-build-env.yml from the main lockfile. Needs
network access (it reads PyPI file metadata); the outputs are
committed so the builds themselves stay offline.
"""
import batools.flatpakbuildenv
batools.flatpakbuildenv.generate(str(pcommand.PROJROOT))
def _github_repo() -> str:
"""Return 'owner/repo' from GITHUB_REPOSITORY or the git remote."""
import os
import subprocess
from efro.error import CleanError
try:
return os.environ['GITHUB_REPOSITORY']
except KeyError:
try:
user_plus_repo: list[str] = (
subprocess.run(
'git config remote.origin.url',
check=True,
shell=True,
capture_output=True,
text=True,
)
.stdout.strip(' \n')
.split('/')
)
return (
user_plus_repo[-2]
+ '/'
+ user_plus_repo[-1].removesuffix('.git')
)
except Exception as e:
raise CleanError(
f'GITHUB_REPOSITORY env var not'
f'set and git remote.origin.url not set.'
f'{e}'
) from e
[docs]
def generate_flathub_manifest() -> None:
"""Generate a Flathub manifest for Ballistica into build/flathub.
This function is intended to be run within a GitHub Actions workflow.
Replaces everything in build/flathub (except .git and flathub.json)
with the manifest, its python-build-env module, and a generated
bombsquad-sources.yml: git at the release's tag plus that release's
prebuilt-inputs archive. The release is the one for the tag that
triggered the workflow, or else the latest one.
"""
# pylint: disable=too-many-locals
import json
import os
import shutil
import subprocess
import urllib.request
from efro.error import CleanError
from efro.terminal import Clr
from efrotools.util import writefile
github_repo = _github_repo()
flatpak_src_dir = os.path.join(pcommand.PROJROOT, 'pconfig', 'flatpak')
flathub_dir = os.path.join(pcommand.PROJROOT, 'build', 'flathub')
os.makedirs(flathub_dir, exist_ok=True)
print(f'{Clr.BLD}Generating Flathub manifest...{Clr.RST}')
# The flathub repo holds only what this writes; Flathub wants
# everything the build installs to come from the app's sources.
for name in os.listdir(flathub_dir):
if name in {'.git', 'flathub.json'}:
continue
path = os.path.join(flathub_dir, name)
if os.path.isdir(path):
shutil.rmtree(path)
else:
os.remove(path)
for filename in ['net.froemling.bombsquad.yml', 'python-build-env.yml']:
shutil.copy2(
os.path.join(flatpak_src_dir, filename),
os.path.join(flathub_dir, filename),
)
print(f' Copied {filename}')
in_tag_workflow = os.environ.get('GITHUB_REF_TYPE') == 'tag'
ref_name = os.environ.get('GITHUB_REF_NAME')
which = f'tags/{ref_name}' if in_tag_workflow else 'latest'
print(f'{Clr.BLD}Fetching GitHub release info ({which})...{Clr.RST}')
asset_name = 'bombsquad_prebuilt_inputs.tar.xz'
try:
api_url = f'https://api.github.com/repos/{github_repo}/releases/{which}'
# Authenticate when we can: anonymous API calls share a 60/hour
# limit per IP, which busy CI runners exhaust (v1.8.2's release
# failed here on exactly that).
headers = {'Accept': 'application/vnd.github+json'}
token = os.environ.get('GITHUB_TOKEN')
if token:
headers['Authorization'] = f'Bearer {token}'
request = urllib.request.Request(api_url, headers=headers)
with urllib.request.urlopen(request) as response:
release_data = json.loads(response.read().decode())
tag = release_data['tag_name']
asset = next(
a for a in release_data['assets'] if a['name'] == asset_name
)
except StopIteration:
raise CleanError(f'No {asset_name} in release {which}.') from None
except Exception as e:
raise CleanError(f'Failed to fetch release info: {e}') from e
digest = asset.get('digest') or ''
if not digest.startswith('sha256:'):
raise CleanError(f'No SHA256 digest for {asset_name}.')
asset_url = asset['browser_download_url']
checksum = digest.removeprefix('sha256:')
print(f' Release tag: {tag}')
print(f' Found asset: {asset_url}')
# Pin the git source to the commit as well as the tag, so a moved
# tag can't change what Flathub builds. In the release workflow
# that's GITHUB_SHA, which also covers a shallow checkout that can't
# resolve the tag itself.
if in_tag_workflow:
commit = os.environ['GITHUB_SHA']
else:
try:
commit = subprocess.run(
['git', 'rev-parse', f'{tag}^{{commit}}'],
cwd=pcommand.PROJROOT,
check=True,
capture_output=True,
text=True,
).stdout.strip()
except subprocess.CalledProcessError as e:
raise CleanError(
f'Could not resolve tag {tag} to a commit: {e.stderr}'
) from e
print(f' Release commit: {commit}')
# The Flathub copy of pconfig/flatpak/bombsquad-sources.yml: the
# code from git, and what git lacks from the release's archive,
# extracted over the checkout.
sources_path = os.path.join(flathub_dir, 'bombsquad-sources.yml')
writefile(
sources_path,
'# Generated by generate_flathub_manifest; do not edit.\n'
'# This file contains the precompiled assets and resources\n'
'# for the release, which are not in git,\n'
'# and are considered closed source.\n'
'# The git source is pinned to the release commit\n'
'# as well as the tag, so a moved tag cannot change\n'
'# what Flathub builds.\n'
'# It contains the following sources:\n'
'# - The icon for the application\n'
'# - The precompiled binaries and resources for the release\n'
'- type: git\n'
f' url: https://github.com/{github_repo}.git\n'
f' tag: {tag}\n'
f' commit: {commit}\n'
'- type: archive\n'
f' url: {asset_url}\n'
f' sha256: {checksum}\n'
' strip-components: 0\n',
)
print(f' Wrote {sources_path}')
print(f'{Clr.BLD}{Clr.GRN}Flathub manifest generation complete!{Clr.RST}')
[docs]
def flatpak_add_release() -> None:
"""Generate the flatpak releases.xml from CHANGELOG.md.
Args: [version]
Writes pconfig/flatpak/net.froemling.bombsquad.releases.xml, which
the flatpak build installs. The file is not in git; it is rebuilt
whole each time, so CHANGELOG.md stays the only record of releases.
It lists the newest few changelog versions, up to and including
``version`` (default: the newest), each dated from its changelog
heading and linking to its changelog section. The release
workflow runs this with the release tag before packing the
prebuilt-inputs archive that carries the file to Flathub.
"""
import os
import re
from xml.etree import ElementTree as ET
from efro.error import CleanError
from efro.terminal import Clr
from batools.changelog import changelog_version, get_changelog_releases
# How many releases to list. Software centers show the newest one
# or two; older history lives in the linked changelog.
max_releases = 10
args = pcommand.get_args()
if len(args) > 1:
raise CleanError('Expected args: [version]')
releases = get_changelog_releases(str(pcommand.PROJROOT))
if not releases:
raise CleanError('No versions found in CHANGELOG.md.')
if args:
version = changelog_version(args[0].removeprefix('v'))
versions = [r[0] for r in releases]
if version not in versions:
raise CleanError(f'No CHANGELOG.md section for version {version}.')
releases = releases[versions.index(version) :]
releases = releases[:max_releases]
# Always the public repo's changelog, whichever repo or fork runs
# this.
changelog_url = (
'https://github.com/efroemling/ballistica/blob/main/CHANGELOG.md'
)
root = ET.Element('releases')
root.append(
ET.Comment(' Generated from CHANGELOG.md by flatpak_add_release. ')
)
for version, date, heading in releases:
release = ET.SubElement(
root, 'release', version=version, date=date, type='stable'
)
description = ET.SubElement(release, 'description')
ET.SubElement(description, 'p').text = (
f'BombSquad {version}. The full list of changes is in the'
f' changelog.'
)
# GitHub's heading anchor: lowercased, punctuation dropped,
# spaces to dashes.
anchor = re.sub(r'[^a-z0-9 _-]', '', heading.lower()).replace(' ', '-')
ET.SubElement(release, 'url', type='details').text = (
f'{changelog_url}#{anchor}'
)
ET.indent(root, space=' ')
releases_xml_path = os.path.join(
pcommand.PROJROOT,
'pconfig',
'flatpak',
'net.froemling.bombsquad.releases.xml',
)
ET.ElementTree(root).write(
releases_xml_path, encoding='utf-8', xml_declaration=True
)
print(
f'{Clr.GRN}Wrote {releases_xml_path}'
f' ({releases[0][0]} back to {releases[-1][0]}).{Clr.RST}'
)
[docs]
def gen_pyembed() -> None:
"""Gen a pyembed .inc file using compiled bytecode.
Args: <in_path> <out_path> [encrypt={0,1}] [ctx=<var_name>]
Replaces gen_encrypted_python_code (encrypt=1) and
gen_flat_data_code (encrypt=0) for pyembed modules.
"""
from efro.error import CleanError
from batools.codegen import gen_pyembed as gen
if len(sys.argv) < 4:
raise CleanError(
'Expected at least 2 args: <in_path> <out_path> '
'[encrypt={0,1}] [ctx=<var>]'
)
encrypt = True
ctx_var = 'internal_py_context'
for arg in sys.argv[4:]:
if arg.startswith('encrypt='):
encrypt = arg.removeprefix('encrypt=') == '1'
elif arg.startswith('ctx='):
ctx_var = arg.removeprefix('ctx=')
else:
raise CleanError(f'Unrecognized arg: {arg!r}')
gen(
projroot=str(pcommand.PROJROOT),
in_path=sys.argv[2],
out_path=sys.argv[3],
encrypt=encrypt,
ctx_var=ctx_var,
)
[docs]
def require_unsandboxed() -> None:
"""Fail right away if running inside Claude Code's command sandbox.
args: a short description of what is being attempted (for the
error message).
For build targets that cannot produce a correct result there:
release archives and anything that uploads. Gradle is forced
offline in the sandbox, so steps that need the network (Crashlytics
mapping/symbol uploads, dependency fetches) fail or are skipped --
and a release artifact that merely *looks* built is worse than no
artifact. Failing in the first second, with the reason, beats
finding out after a full build (or after shipping it).
"""
from efro.error import CleanError
from batools.build import in_claude_sandbox
if len(sys.argv) != 3:
raise CleanError('Expected 1 arg: a description of the task.')
if in_claude_sandbox():
raise CleanError(
f"Refusing to run {sys.argv[2]} inside Claude Code's command"
' sandbox: Gradle has no network access there (it is forced'
' offline), so this cannot produce a correct result. Run it'
' from a normal shell instead.'
)
# Docs-generation hack; import some stuff that we likely only forward-declared
# in our actual source code so that docs tools can find it.
from typing import (Coroutine, Any, Literal, Callable,
Generator, Awaitable, Sequence, Self, TypeIs)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum