Skip to content

feature(flex): build arm64 python whls for use on Flex #10

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,6 @@ dmypy.json

# Pyre type checker
.pyre/

# Flex package output folders
flex/packages/**/output/
8 changes: 8 additions & 0 deletions flex/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# TODO

- Understand: Review the current PyPI-style publishing code to see how packages are published and what assets are needed.
- Adapt Flex Build to fit into this process.

## 4/5

- works on y3rsh's M1 macbook
266 changes: 266 additions & 0 deletions flex/local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
#!/usr/bin/env python3
# /// script
# requires-python = "==3.10.*"
# dependencies = [
# "rich",
# ]
# ///

"""
Dependencies:
- uv
- Docker (with buildx configured)

Usage:
uv run local.py
"""

import os
import subprocess
from pathlib import Path
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from rich.live import Live

# Create a console without timestamps or file logging
console = Console(log_time=False, log_path=False)

PACKAGES_PATH = Path(Path(__file__).parent, "packages")
if not PACKAGES_PATH.exists():
console.log("[bold red]Error:[/bold red] The 'packages' directory does not exist.")
exit(1)


def package_name_from_path(package_path: Path) -> str:
"""
Generate a package name from its path.
Example: If package is at packages/foo/1.0.0, returns 'foo:1.0.0'
"""
return f"{package_path.parent.name}:{package_path.name}"


def test_package_name_from_path(package_path: Path) -> str:
"""
Generate a test image tag for the package.
Example: If package is at packages/foo/1.0.0, returns 'foo-test:1.0.0'
"""
return f"{package_path.parent.name}-test:{package_path.name}"


def find_all_packages() -> list:
"""
Find all package directories in the 'packages' directory.
Returns:
list: A list of Path objects representing each package directory.
"""
package_parents = [d for d in PACKAGES_PATH.iterdir() if d.is_dir()]
packages = [d for parent in package_parents for d in parent.iterdir() if d.is_dir()]
if not packages:
console.log(
"[bold red]Error:[/bold red] No packages found in the 'packages' directory."
)
exit(1)
console.log(f"[bold green]Found {len(packages)} package(s):[/bold green]")
for package in packages:
console.log(f" - {package_name_from_path(package)}")
return packages


def run_command(command: list, cwd: str = None) -> bool:
"""
Run a shell command and log its output.

Args:
command (list): List of command arguments.
cwd (str or Path, optional): Working directory for the command.

Returns:
bool: True if the command succeeded, False otherwise.
"""
console.log(
f"[bold green]Running:[/bold green] {' '.join(command)} (cwd: {cwd or os.getcwd()})"
)
try:
result = subprocess.run(
command, cwd=cwd, check=True, text=True, capture_output=True
)
console.log(result.stdout)
return True
except subprocess.CalledProcessError as e:
console.log(f"[bold red]Error:[/bold red] {e.stderr}")
return False


def run_command_live(
command: list, cwd: str = None, title: str = "Running Command"
) -> str:
"""
Run a shell command with live output using Rich Live.

Args:
command (list): List of command arguments.
cwd (str or Path, optional): Working directory for the command.
title (str): Title for the live panel.

Returns:
str: The combined output of the command.

Raises:
RuntimeError: If the command fails.
"""
output_lines = []
live_panel = Panel(
Text("", overflow="fold"), title=title, border_style="blue", expand=True
)
with Live(live_panel, refresh_per_second=4) as live:
process = subprocess.Popen(
command,
cwd=cwd,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
universal_newlines=True,
)
for line in process.stdout:
output_lines.append(line.rstrip())
live.update(
Panel(
Text("\n".join(output_lines), overflow="fold"),
title=title,
border_style="blue",
expand=True,
)
)
process.wait()
if process.returncode != 0:
raise RuntimeError(
f"Command {' '.join(command)} failed with exit code {process.returncode}"
)
return "\n".join(output_lines)


def create_output_directory(package: Path) -> None:
"""
Create the output directory (package/output).

Args:
package (Path): The package directory.
"""
output_dir = Path(package, "output")
output_dir.mkdir(parents=True, exist_ok=True)
console.log(f"[bold green]Output directory created at {output_dir}[/bold green]")


def build_docker_image(package: Path) -> str:
"""
Build the Docker image and export the wheel using docker buildx.

Args:
package (Path): The package directory.

Returns:
str: The captured output from the Docker build command.
"""
build_cmd = [
"docker",
"buildx",
"build",
"--platform",
"linux/arm64",
"-t",
package_name_from_path(package),
"--output",
"type=local,dest=output",
".",
]
console.log(
f"[bold green]Building Docker image for {package_name_from_path(package)}...[/bold green]"
)
output = run_command_live(
build_cmd,
cwd=str(package),
title=f"Docker Build: {package_name_from_path(package)}",
)
console.log(
f"[green]Docker build completed for {package_name_from_path(package)}![/green]"
)
return output


def build_and_run_test_container(package: Path) -> str:
"""
Build and run the Docker container to test the installation of the wheel.

Args:
package (Path): The package directory used to tag the Docker image.

Returns:
str: The combined output from the build and run test commands.
"""
image_tag = test_package_name_from_path(package)
build_cmd = ["docker", "build", "-f", "DockerfileTest", "-t", image_tag, "."]
console.log(f"[bold green]Building Docker test image '{image_tag}'...[/bold green]")
build_output = run_command_live(
build_cmd, cwd=str(package), title=f"Test Build: {image_tag}"
)
console.log(f"[green]Docker test image '{image_tag}' built successfully![/green]")

run_cmd = ["docker", "run", "--rm", image_tag]
console.log(
f"[bold green]Running Docker test container from image '{image_tag}'...[/bold green]"
)
run_output = run_command_live(
run_cmd, cwd=str(package), title=f"Test Run: {image_tag}"
)
console.log(f"[green]Test container '{image_tag}' ran successfully![/green]")

return f"{build_output}\n{run_output}"


def main():
"""
Main process to build the packages and run tests.
"""
console.rule("[bold blue]Build all packages[/bold blue]")
packages = find_all_packages()

for package in packages:
console.log(
f"\n[bold yellow]Processing package: {package_name_from_path(package)}[/bold yellow]"
)
create_output_directory(package)
try:
build_docker_image(package)
except RuntimeError as e:
console.log(f"[bold red]Error:[/bold red] {e}")
continue

console.rule("[bold blue]Build Process Completed Successfully! 🎉[/bold blue]")
console.rule("[bold magenta]Running Tests for All Packages[/bold magenta]")

for package in packages:
package_name = package_name_from_path(package)
console.print(
f"[bold yellow]Running tests for package: {package_name}...[/bold yellow]"
)
try:
test_output = build_and_run_test_container(package)
except RuntimeError as e:
test_output = f"Error running tests: {e}"
wrapped_text = Text(test_output, overflow="fold")
console.print(
Panel(
wrapped_text,
title=f"Test: {package_name}",
border_style="yellow",
expand=True,
)
)

console.rule("[bold magenta]All Tests Completed 🎉[/bold magenta]")


if __name__ == "__main__":
main()
38 changes: 38 additions & 0 deletions flex/packages/psycard/2.2.1/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Use the official Python 3.10 image for the target platform.
# The --platform argument will be honored by Buildx.
FROM --platform=$BUILDPLATFORM python:3.10-bullseye AS builder

ENV DEBIAN_FRONTEND=noninteractive

# Prevent services from starting during package installation.
RUN echo '#!/bin/sh\nexit 101' > /usr/sbin/policy-rc.d && chmod +x /usr/sbin/policy-rc.d

# Install build dependencies and the AArch64 cross-compiler.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
build-essential \
swig \
libpcsclite-dev \
git \
gcc-aarch64-linux-gnu && \
rm -rf /var/lib/apt/lists/* && \
rm /usr/sbin/policy-rc.d

# Set the cross-compiler environment variables so that C extensions are built for ARM64.
ENV CC=aarch64-linux-gnu-gcc
ENV CXX=aarch64-linux-gnu-g++

# Upgrade pip and install the build backend.
RUN pip install --upgrade pip && pip install build


WORKDIR /app
# Clone the specific version of pyscard
RUN git clone --depth 1 --branch 2.2.1 https://github.com/LudovicRousseau/pyscard.git .

# Build the wheel; the output will be placed in /app/dist.
RUN python -m build

# buildx will automatically detect the output directory
FROM scratch AS exporter
COPY --from=builder /app/dist/ /
20 changes: 20 additions & 0 deletions flex/packages/psycard/2.2.1/DockerfileTest
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Use an ARM-specific Python 3.10 image (Debian Bullseye)
FROM arm64v8/python:3.10-bullseye

# Set working directory for clarity
WORKDIR /app

# Update apt and install the PC/SC development package (provides libpcsclite.so.1)
# https://layers.openembedded.org/layerindex/recipe/1079/
RUN apt-get update && \
apt-get install -y libpcsclite-dev && \
rm -rf /var/lib/apt/lists/*

# Copy the built wheel(s) from the local 'output' directory into the container
COPY output /wheels

# Install the whl
RUN pip install /wheels/*.whl

# Set the container to run a test: import the module and print a success message
CMD ["python", "-c", "import smartcard; print('pyscard imported successfully!')"]