# Released under the MIT License. See LICENSE for details.
#
"""A nice collection of ready-to-use pcommands for this package."""
from __future__ import annotations
# Note: import as little as possible here at the module level to
# keep launch times fast for small snippets.
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()
# pylint: disable=too-many-locals,too-many-statements
[docs]
def generate_flathub_manifest() -> None:
"""Generate a Flathub manifest for Ballistica and push to submodule.
This function is intended to be run within a GitHub Actions workflow.
This function:
1. Copies files from config/flatpak/ to config/flatpak/flathub
2. Generates the manifest from template using latest GitHub release info
"""
import json
import os
import shutil
import urllib.request
import subprocess
from efro.error import CleanError
from efro.terminal import Clr
pcommand.disallow_in_batch()
try:
github_repo = 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('/')
)
github_repo = (
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
# Paths
flatpak_src_dir = os.path.join(pcommand.PROJROOT, 'config', 'flatpak')
flathub_dir = os.path.join(pcommand.PROJROOT, 'build', 'flathub')
template_path = os.path.join(
flatpak_src_dir, 'net.froemling.bombsquad.yml.template'
)
os.makedirs(flathub_dir, exist_ok=True)
manifest_path = os.path.join(flathub_dir, 'net.froemling.bombsquad.yml')
print(f'{Clr.BLD}Generating Flathub manifest...{Clr.RST}')
# Step 1: Copy files from config/flatpak/ to config/flatpak/flathub
print(
f'{Clr.BLD}Copying files from {flatpak_src_dir} to '
f'{flathub_dir}...{Clr.RST}'
)
# List of files to copy (skip the flathub directory itself)
files_to_copy = [
'net.froemling.bombsquad.appdata.xml',
'net.froemling.bombsquad.desktop',
'net.froemling.bombsquad.releases.xml',
]
for filename in files_to_copy:
src = os.path.join(flatpak_src_dir, filename)
dst = os.path.join(flathub_dir, filename)
if os.path.exists(src):
shutil.copy2(src, dst)
print(f' Copied {filename}')
else:
print(f' Warning: {filename} not found at {src}')
# Step 2: Get latest release information from GitHub
print(f'{Clr.BLD}Fetching latest GitHub release info...{Clr.RST}')
try:
api_url = f'https://api.github.com/repos/{github_repo}/releases/latest'
req = urllib.request.Request(api_url)
with urllib.request.urlopen(req) as response:
release_data = json.loads(response.read().decode())
# Find the linux_build_env.tar asset
asset: dict = {}
asset_url = None
asset_name = 'linux_build_env.tar'
for asset in release_data.get('assets', []):
if asset['name'] == asset_name:
asset_url = asset['browser_download_url']
break
if not asset_url:
raise CleanError(
f'Could not find {asset_name} in latest release assets'
)
print(f' Found asset: {asset_url}')
# Extract version from release tag
version = release_data.get('tag_name', '').lstrip('v')
if not version:
raise CleanError('Could not extract version from release tag')
print(f' Release version: {version}')
# Extract release date from published_at field
release_date = release_data.get('published_at', '')
if not release_date:
raise CleanError('Could not extract release date from API')
# Convert ISO format date (e.g., '2026-01-25T12:34:56Z')
# to YYYY-MM-DD
release_date = release_date.split('T')[0]
print(f' Release date: {release_date}')
print(f'{Clr.BLD}Getting SHA256 checksum...{Clr.RST}')
digest = asset.get('digest')
if not digest or not digest.startswith('sha256:'):
msg = 'No SHA256 digest found in GitHub release asset'
raise CleanError(msg)
checksum = digest.split(':', 1)[1]
except Exception as e:
raise CleanError(f'Failed to fetch release info: {e}') from e
print(f'{Clr.BLD}Generating manifest from template...{Clr.RST}')
with open(template_path, 'r', encoding='utf-8') as infile:
template = infile.read()
def _remove_comments_from_xml_template(content: str) -> str:
import re
# Pattern matches lines that start with optional spaces/tabs then '#'
# This removes the entire line including the newline
pattern = r'^\s*#.*$\n?'
result = re.sub(pattern, '', content, flags=re.MULTILINE)
return result
template = _remove_comments_from_xml_template(template)
# Replace placeholders
manifest_content = template.replace('{ ARCHIVE_URL }', asset_url)
manifest_content = manifest_content.replace('{ SHA256_CHECKSUM }', checksum)
with open(manifest_path, 'w', encoding='utf-8') as outfile:
outfile.write(manifest_content)
print(f' Generated manifest at {manifest_path}')
# Call generate_flatpak_release_manifest with
# the extracted version, repo URL, and date
print(f'{Clr.BLD}Generating Flatpak release manifest...{Clr.RST}')
generate_flatpak_release_manifest(
version, asset_url, checksum, github_repo, release_date
)
print(f'{Clr.BLD}{Clr.GRN}Flathub manifest generation complete!{Clr.RST}')
# pylint: disable=too-many-locals,too-many-statements
[docs]
def generate_flatpak_release_manifest(
version: str,
asset_url: str,
checksum: str,
github_repo: str,
release_date: str,
) -> None:
"""Generate a Flatpak release manifest for Ballistica.
This function:
1. Adds a new release entry to net.froemling.bombsquad.releases.xml
2. Updates the net.froemling.bombsquad.releases.xml file with the
new release information
Args:
version: Version string from GitHub release (e.g., '1.7.60')
asset_url: URL to the release asset
checksum: SHA256 checksum of the release asset
github_repo: GitHub repository in format 'owner/repo'
release_date: Release date in YYYY-MM-DD format
"""
import os
from xml.etree import ElementTree as ET
from efro.error import CleanError
from efro.terminal import Clr
pcommand.disallow_in_batch()
# Paths
flathub_dir = os.path.join(pcommand.PROJROOT, 'build', 'flathub')
releases_xml_path = os.path.join(
flathub_dir, 'net.froemling.bombsquad.releases.xml'
)
print(f'{Clr.BLD}Adding release {version} to releases.xml...{Clr.RST}')
# Parse the existing releases.xml
if not os.path.exists(releases_xml_path):
raise CleanError(f'releases.xml not found at {releases_xml_path}')
try:
tree = ET.parse(releases_xml_path)
root = tree.getroot()
except ET.ParseError as e:
raise CleanError(f'Failed to parse releases.xml: {e}') from e
# Check if release with this version already exists
existing_release = root.find(f".//release[@version='{version}']")
if existing_release is not None:
print(
f'{Clr.YLW}Warning: Release {version} '
f'already exists in releases.xml, skipping...{Clr.RST}'
)
return
# Create new release element
new_release = ET.Element('release')
new_release.set('version', version)
new_release.set('date', release_date)
new_release.set('urgency', 'low')
new_release.set('type', 'stable')
# Add description
description = ET.SubElement(new_release, 'description')
p = ET.SubElement(description, 'p')
p.text = get_changelog(version)
# Add URL element for release page
release_url = ET.SubElement(new_release, 'url')
release_url.text = (
f'https://github.com/{github_repo}/releases/tag/v{version}'
)
# Add artifacts section with binary information
artifacts = ET.SubElement(new_release, 'artifacts')
# Add source artifact
source_artifact = ET.SubElement(artifacts, 'artifact')
source_artifact.set('type', 'source')
source_location = ET.SubElement(source_artifact, 'location')
source_location.text = (
f'https://github.com/{github_repo}/archive/refs/tags/v{version}.tar.gz'
)
# Add binary artifact for linux
binary_artifact = ET.SubElement(artifacts, 'artifact')
binary_artifact.set('type', 'source')
binary_artifact.set('platform', 'x86_64-linux-gnu')
binary_location = ET.SubElement(binary_artifact, 'location')
binary_location.text = asset_url
binary_checksum = ET.SubElement(binary_artifact, 'checksum')
binary_checksum.set('type', 'sha256')
binary_checksum.text = checksum
# Insert the new release at the beginning (after the root element)
root.insert(0, new_release)
# Format the XML with proper indentation
def _indent(elem: ET.Element[str], level: int = 0) -> None:
"""Add pretty-printing indentation to XML tree."""
indent_str = '\n' + (' ' * level)
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = indent_str + ' '
if not elem.tail or not elem.tail.strip():
elem.tail = indent_str
child: ET.Element | None = None
for child in elem:
_indent(child, level + 1)
if child and (not child.tail or not child.tail.strip()):
child.tail = indent_str
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = indent_str
_indent(root)
# Write back to file
try:
tree.write(releases_xml_path, encoding='utf-8', xml_declaration=True)
print(f' Added release {version} to releases.xml')
print(f' Generated flatpak release manifest at {releases_xml_path}')
print(
f'{Clr.BLD}{Clr.GRN}Flatpak release manifest '
f'generation complete!{Clr.RST}'
)
except Exception as e:
raise CleanError(f'Failed to write releases.xml: {e}') from e
[docs]
def get_changelog(version: str | None = None) -> str:
"""Get changelog text for a given version from CHANGELOG.md."""
import re
import os
from efro.error import CleanError
from efro.terminal import Clr
called_from_other_function = version is not None
pcommand.disallow_in_batch()
if version is None:
args = pcommand.get_args()
if len(args) != 1:
raise CleanError('Expected 1 arg: version')
version = args[0]
changelog_path = os.path.join(pcommand.PROJROOT, 'CHANGELOG.md')
if not os.path.exists(changelog_path):
raise CleanError(f'CHANGELOG.md not found at {changelog_path}')
with open(changelog_path, 'r', encoding='utf-8') as infile:
changelog_content = infile.read()
# Regex to find the section for the given version
pattern = rf'^###\s+{re.escape(version)}\b.*?\n(.*?)(?=^###\s+|\Z)'
match = re.search(pattern, changelog_content, re.DOTALL | re.MULTILINE)
if not match:
raise CleanError(f'Changelog entry for version {version} not found.')
changelog_text = match.group(1).strip()
if not called_from_other_function:
print(
f'{Clr.BLD}Changelog for version '
f'{version}:{Clr.RST}\n{changelog_text}'
)
return changelog_text
# 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)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum