Repository: beeware/briefcase
Pull request #1564 (2023-12-06 23:59:58)

Add support to build AppImages on ARM
## Changes
- Add support for packaging AppImages for ARM
- Bumps default manylinux image from `manylinux2014` to `manylinux_2_28`
  - The linuxdeploy tool for ARM requires at least glibc 2.25
  - Python users are around 90% compatible with glibc 2.28

## Notes
- arm64 support is available for both docker and non-docker builds
- It becomes more awkward for armv7 since manylinux doesn't publish an image for that arch
  - I added a warning during the create process but I didn't want to prevent using docker altogether for 32-bit arm since users could use whatever docker image they want
- The linuxdeploy support for ARM requires armhf....so, armv6 support is excluded

## PR Checklist:
<!--- Go over all the following points, and put an `x` in all the boxes that apply. -->
- [x] All new features have been tested
- [x] All new features have been documented
- [X] I have read the **CONTRIBUTING.md** file
- [X] I will abide by the code of conduct


Patch:
diff --git a/changes/1564.feature.rst b/changes/1564.feature.rst
new file mode 100644
index 00000000..9bcbae2d
--- /dev/null
+++ b/changes/1564.feature.rst
@@ -0,0 +1,1 @@
+AppImages can now be built for the ARM architecture.
diff --git a/changes/1564.removal.rst b/changes/1564.removal.rst
new file mode 100644
index 00000000..5a0ffc11
--- /dev/null
+++ b/changes/1564.removal.rst
@@ -0,0 +1,1 @@
+New projects will now use ``manylinux_2_28`` instead of ``manylinux2014`` to create AppImages in Docker.
diff --git a/docs/reference/platforms/index.rst b/docs/reference/platforms/index.rst
index fbce930d..6019e2ca 100644
--- a/docs/reference/platforms/index.rst
+++ b/docs/reference/platforms/index.rst
@@ -64,7 +64,7 @@ Platform support
 +---------+-----------------+--------+-------+-----+--------+-------+-----+--------+-----+-------+
 | iOS     | |iOS|_          | |f|    | |y|   |     |        |       |     |        |     |       |
 +---------+-----------------+--------+-------+-----+--------+-------+-----+--------+-----+-------+
-| Linux   | |AppImage|_     | |v|    |       |     |        |       | |v| | |v|    |     |       |
+| Linux   | |AppImage|_     | |v|    | |v|   |     |        |       | |v| | |v|    | |v| | |v|   |
 +         +-----------------+--------+-------+-----+--------+-------+-----+--------+-----+-------+
 |         | |Flatpak|_      |        |       |     |        |       | |v| | |f|    | |v| | |v|   |
 +         +-----------------+--------+-------+-----+--------+-------+-----+--------+-----+-------+
diff --git a/docs/reference/platforms/linux/appimage.rst b/docs/reference/platforms/linux/appimage.rst
index 14ef7815..22130bb2 100644
--- a/docs/reference/platforms/linux/appimage.rst
+++ b/docs/reference/platforms/linux/appimage.rst
@@ -9,7 +9,7 @@ AppImage
 +--------+-------+-----+--------+-------+-----+--------+-----+-------+
 | x86‑64 | arm64 | x86 | x86‑64 | arm64 | x86 | x86‑64 | arm | arm64 |
 +========+=======+=====+========+=======+=====+========+=====+=======+
-| |v|    |       |     |        |       | |v| | |v|    |     |       |
+| |v|    | |v|   |     |        |       | |v| | |v|    | |v| | |v|   |
 +--------+-------+-----+--------+-------+-----+--------+-----+-------+
 
 .. admonition:: Best effort support
diff --git a/src/briefcase/bootstraps/pursuedpybear.py b/src/briefcase/bootstraps/pursuedpybear.py
index 15f682d1..18300468 100644
--- a/src/briefcase/bootstraps/pursuedpybear.py
+++ b/src/briefcase/bootstraps/pursuedpybear.py
@@ -121,7 +121,7 @@ system_runtime_requires = [
 
     def pyproject_table_linux_appimage(self):
         return """
-manylinux = "manylinux2014"
+manylinux = "manylinux_2_28"
 
 system_requires = [
 # ?? FIXME
diff --git a/src/briefcase/bootstraps/pygame.py b/src/briefcase/bootstraps/pygame.py
index 51f7367e..f009fbea 100644
--- a/src/briefcase/bootstraps/pygame.py
+++ b/src/briefcase/bootstraps/pygame.py
@@ -122,7 +122,7 @@ system_runtime_requires = [
 
     def pyproject_table_linux_appimage(self):
         return """
-manylinux = "manylinux2014"
+manylinux = "manylinux_2_28"
 
 system_requires = [
 ]
diff --git a/src/briefcase/bootstraps/toga.py b/src/briefcase/bootstraps/toga.py
index 71e20aba..58e541db 100644
--- a/src/briefcase/bootstraps/toga.py
+++ b/src/briefcase/bootstraps/toga.py
@@ -158,7 +158,7 @@ system_runtime_requires = [
 
     def pyproject_table_linux_appimage(self):
         return """
-manylinux = "manylinux2014"
+manylinux = "manylinux_2_28"
 
 system_requires = [
     # Needed to compile pycairo wheel
diff --git a/src/briefcase/integrations/linuxdeploy.py b/src/briefcase/integrations/linuxdeploy.py
index 5be75910..1eb780ac 100644
--- a/src/briefcase/integrations/linuxdeploy.py
+++ b/src/briefcase/integrations/linuxdeploy.py
@@ -50,16 +50,32 @@ class LinuxDeployBase(ABC):
         """The folder on the local filesystem that contains the file_name."""
 
     @classmethod
-    def arch(cls, host_arch: str) -> str:
+    def arch(cls, tools: ToolCache) -> str:
         """The architecture defined (and supported) by linuxdeploy for AppImages."""
+        system_arch = tools.host_arch
+
+        # If Python is 32 bit, then use 32 bit linuxdeploy regardless of hardware.
+        # It is non-trivial to determine if Linux is 32 bit or 64 bit; so, this uses
+        # Python's bitness as a proxy for Linux's bitness. Furthermore, though, pip
+        # will install 32 bit packages if Python is 32 bit. So, using 32 bit
+        # linuxdeploy in this case ensures the entire resulting AppImage is consistent.
+        if tools.is_32bit_python:
+            system_arch = {
+                "aarch64": "armv8l",
+                "x86_64": "i686",
+            }.get(tools.host_arch, tools.host_arch)
+
         try:
             return {
                 "x86_64": "x86_64",
                 "i686": "i386",
-            }[host_arch]
+                "armv7l": "armhf",
+                "armv8l": "armhf",
+                "aarch64": "aarch64",
+            }[system_arch]
         except KeyError as e:
             raise UnsupportedHostError(
-                f"Linux AppImages cannot be built on {host_arch}."
+                f"Linux AppImages cannot be built on {tools.host_arch}."
             ) from e
 
     def exists(self) -> bool:
@@ -222,7 +238,7 @@ class LinuxDeployQtPlugin(LinuxDeployPluginBase, ManagedTool):
 
     @property
     def file_name(self) -> str:
-        return f"linuxdeploy-plugin-qt-{self.arch(self.tools.host_arch)}.AppImage"
+        return f"linuxdeploy-plugin-qt-{self.arch(self.tools)}.AppImage"
 
     @property
     def download_url(self) -> str:
@@ -333,7 +349,7 @@ class LinuxDeploy(LinuxDeployBase, ManagedTool):
 
     @property
     def file_name(self) -> str:
-        return f"linuxdeploy-{self.arch(self.tools.host_arch)}.AppImage"
+        return f"linuxdeploy-{self.arch(self.tools)}.AppImage"
 
     @property
     def download_url(self) -> str:
diff --git a/src/briefcase/platforms/linux/appimage.py b/src/briefcase/platforms/linux/appimage.py
index 09447ceb..463b01a4 100644
--- a/src/briefcase/platforms/linux/appimage.py
+++ b/src/briefcase/platforms/linux/appimage.py
@@ -44,7 +44,7 @@ class LinuxAppImagePassiveMixin(LinuxMixin):
 
     def binary_name(self, app):
         safe_name = app.formal_name.replace(" ", "_")
-        arch = LinuxDeploy.arch(self.tools.host_arch)
+        arch = LinuxDeploy.arch(self.tools)
         return f"{safe_name}-{app.version}-{arch}.AppImage"
 
     def binary_path(self, app):
@@ -175,13 +175,21 @@ class LinuxAppImageCreateCommand(
     def output_format_template_context(self, app: AppConfig):
         context = super().output_format_template_context(app)
 
-        # Add the manylinux tag to the template context.
         try:
-            tag = getattr(app, "manylinux_image_tag", "latest")
             manylinux_arch = {
                 "x86_64": "x86_64",
                 "i386": "i686",
-            }[LinuxDeploy.arch(self.tools.host_arch)]
+                "aarch64": "aarch64",
+            }[LinuxDeploy.arch(self.tools)]
+        except KeyError:
+            manylinux_arch = LinuxDeploy.arch(self.tools)
+            self.logger.warning(
+                f"There is no manylinux base image for {manylinux_arch}"
+            )
+
+        # Add the manylinux tag to the template context.
+        try:
+            tag = getattr(app, "manylinux_image_tag", "latest")
             context["manylinux_image"] = f"{app.manylinux}_{manylinux_arch}:{tag}"
             if app.manylinux in {"manylinux1", "manylinux2010", "manylinux2014"}:
                 context["vendor_base"] = "centos"
@@ -190,9 +198,7 @@ class LinuxAppImageCreateCommand(
             elif app.manylinux.startswith("manylinux_2_"):
                 context["vendor_base"] = "almalinux"
             else:
-                raise BriefcaseConfigError(
-                    f"""Unknown manylinux tag {app.manylinux!r}"""
-                )
+                raise BriefcaseConfigError(f"Unknown manylinux tag {app.manylinux!r}")
         except AttributeError:
             pass
 


Test patch:
diff --git a/tests/commands/new/test_build_context.py b/tests/commands/new/test_build_context.py
index 05f44a47..20b51e17 100644
--- a/tests/commands/new/test_build_context.py
+++ b/tests/commands/new/test_build_context.py
@@ -198,7 +198,7 @@ system_runtime_requires = [
 ]
 """,
         pyproject_table_linux_appimage="""
-manylinux = "manylinux2014"
+manylinux = "manylinux_2_28"
 
 system_requires = [
     # Needed to compile pycairo wheel
@@ -578,7 +578,7 @@ system_runtime_requires = [
 ]
 """,
         pyproject_table_linux_appimage="""
-manylinux = "manylinux2014"
+manylinux = "manylinux_2_28"
 
 system_requires = [
 # ?? FIXME
@@ -748,7 +748,7 @@ system_runtime_requires = [
 ]
 """,
         pyproject_table_linux_appimage="""
-manylinux = "manylinux2014"
+manylinux = "manylinux_2_28"
 
 system_requires = [
 ]
@@ -1178,7 +1178,7 @@ system_runtime_requires = [
 ]
 """,
         pyproject_table_linux_appimage="""
-manylinux = "manylinux2014"
+manylinux = "manylinux_2_28"
 
 system_requires = [
     # Needed to compile pycairo wheel
diff --git a/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py b/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py
index bfbe57bc..6560c029 100644
--- a/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py
+++ b/tests/integrations/linuxdeploy/test_LinuxDeploy__properties.py
@@ -19,17 +19,23 @@ def test_file_path(linuxdeploy, mock_tools):
 
 
 @pytest.mark.parametrize(
-    "host_os, host_arch, linuxdeploy_arch",
+    "host_os, host_arch, is_32bit_python, linuxdeploy_arch",
     [
-        ("Linux", "x86_64", "x86_64"),
-        ("Linux", "i686", "i386"),
-        ("Darwin", "x86_64", "x86_64"),
+        ("Linux", "x86_64", False, "x86_64"),
+        ("Linux", "x86_64", True, "i386"),
+        ("Linux", "i686", True, "i386"),
+        ("Linux", "aarch64", True, "armhf"),
+        ("Linux", "aarch64", False, "aarch64"),
+        ("Linux", "armv7l", True, "armhf"),
+        ("Linux", "armv8l", True, "armhf"),
+        ("Darwin", "x86_64", False, "x86_64"),
     ],
 )
-def test_file_name(mock_tools, host_os, host_arch, linuxdeploy_arch):
+def test_file_name(mock_tools, host_os, host_arch, is_32bit_python, linuxdeploy_arch):
     """Linuxdeploy filename is architecture dependent."""
     mock_tools.host_os = host_os
     mock_tools.host_arch = host_arch
+    mock_tools.is_32bit_python = is_32bit_python
 
     linuxdeploy = LinuxDeploy(mock_tools)
 
diff --git a/tests/platforms/linux/appimage/test_create.py b/tests/platforms/linux/appimage/test_create.py
index 0ab0bd16..95d0c020 100644
--- a/tests/platforms/linux/appimage/test_create.py
+++ b/tests/platforms/linux/appimage/test_create.py
@@ -175,6 +175,32 @@ def test_finalize_nodocker(create_command, first_app_config, capsys):
                 "use_non_root_user": True,
             },
         ),
+        # Linux on aarch64 hardware
+        (
+            "manylinux_2_28",
+            None,
+            "Linux",
+            "aarch64",
+            False,
+            {
+                "manylinux_image": "manylinux_2_28_aarch64:latest",
+                "vendor_base": "almalinux",
+                "use_non_root_user": True,
+            },
+        ),
+        # Linux on arm hardware
+        (
+            "manylinux_2_28",
+            None,
+            "Linux",
+            "armv7l",
+            False,
+            {
+                "manylinux_image": "manylinux_2_28_armhf:latest",
+                "vendor_base": "almalinux",
+                "use_non_root_user": True,
+            },
+        ),
         # macOS on x86_64
         (
             "manylinux2014",