Repository: beeware/briefcase

Issue:
RPM packaging for OpenSUSE fails to move package to `dist` directory
### Describe the bug

Packaging an RPM for OpenSUSE fails when Briefcase attempts to move the package to the `dist` directory.

```
FileNotFoundError: [Errno 2] No such file or directory: '/home/russell/tmp/beeware/helloworld/build/helloworld/opensuse-tumbleweed/20240109/rpmbuild/RPMS/x86_64/helloworld-0.0.1-1.el20240109.x86_64.rpm'
```

### Steps to reproduce

`briefcase package linux system --target opensuse/tumbleweed`

### Expected behavior

The RPM package is properly moved to the `dist` directory.

The RPM is called `helloworld-0.0.1-1.x86_64.rpm`; not `helloworld-0.0.1-1.el20240109.x86_64.rpm`. OpenSUSE appears to be leaving off the distro tag.

### Screenshots

_No response_

### Environment

- Operating System: pop os 22.04
- Python version: 3.12.0
- Software versions:
  - Briefcase: `0.3.17.dev192+g33a66c4a`
  - Toga: `0.4.1`

### Logs

[briefcase.2024_01_10-12_19_16.package.log](https://github.com/beeware/briefcase/files/13890925/briefcase.2024_01_10-12_19_16.package.log)


### Additional context

_No response_


Discussion:

Patch:
diff --git a/changes/1595.bugfix.rst b/changes/1595.bugfix.rst
new file mode 100644
index 000000000..12dc3cdb2
--- /dev/null
+++ b/changes/1595.bugfix.rst
@@ -0,0 +1,1 @@
+Linux System RPM packaging for openSUSE Tumbleweed no longer errors with ``FileNotFoundError``.
diff --git a/src/briefcase/bootstraps/toga.py b/src/briefcase/bootstraps/toga.py
index 58e541dba..68bb2b5b2 100644
--- a/src/briefcase/bootstraps/toga.py
+++ b/src/briefcase/bootstraps/toga.py
@@ -118,7 +118,7 @@ def pyproject_table_linux_system_suse(self):
     # Needed to provide GTK
     "gtk3",
     # Needed to support Python bindings to GTK
-    "gobject-introspection", "typelib(Gtk)=3.0",
+    "gobject-introspection", "typelib(Gtk) = 3.0",
     # Dependencies that GTK looks for at runtime
     "libcanberra-gtk3-0",
     # Needed to provide WebKit2 at runtime
diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py
index 6815b48ee..6a986c4cd 100644
--- a/src/briefcase/platforms/linux/system.py
+++ b/src/briefcase/platforms/linux/system.py
@@ -1,10 +1,11 @@
+from __future__ import annotations
+
 import gzip
 import os
 import re
 import subprocess
 import sys
 from pathlib import Path
-from typing import List
 
 from briefcase.commands import (
     BuildCommand,
@@ -261,18 +262,35 @@ def pkg_abi(self, app: AppConfig) -> str:
     def distribution_filename(self, app: AppConfig) -> str:
         if app.packaging_format == "deb":
             return (
-                f"{app.app_name}_{app.version}-{getattr(app, 'revision', 1)}"
-                f"~{app.target_vendor}-{app.target_codename}_{self.deb_abi(app)}.deb"
+                f"{app.app_name}"
+                f"_{app.version}"
+                f"-{getattr(app, 'revision', 1)}"
+                f"~{app.target_vendor}"
+                f"-{app.target_codename}"
+                f"_{self.deb_abi(app)}"
+                ".deb"
             )
         elif app.packaging_format == "rpm":
+            # openSUSE doesn't include a distro tag
+            if app.target_vendor_base == SUSE:
+                distro_tag = ""
+            else:
+                distro_tag = f".{self.rpm_tag(app)}"
             return (
-                f"{app.app_name}-{app.version}-{getattr(app, 'revision', 1)}"
-                f".{self.rpm_tag(app)}.{self.rpm_abi(app)}.rpm"
+                f"{app.app_name}"
+                f"-{app.version}"
+                f"-{getattr(app, 'revision', 1)}"
+                f"{distro_tag}"
+                f".{self.rpm_abi(app)}"
+                f".rpm"
             )
         elif app.packaging_format == "pkg":
             return (
-                f"{app.app_name}-{app.version}-{getattr(app, 'revision', 1)}"
-                f"-{self.pkg_abi(app)}.pkg.tar.zst"
+                f"{app.app_name}"
+                f"-{app.version}"
+                f"-{getattr(app, 'revision', 1)}"
+                f"-{self.pkg_abi(app)}"
+                ".pkg.tar.zst"
             )
         else:
             raise BriefcaseCommandError(
@@ -513,7 +531,7 @@ def verify_system_packages(self, app: AppConfig):
             system_installer,
         ) = self._system_requirement_tools(app)
 
-        if system_verify is None:
+        if not (system_verify and self.tools.shutil.which(system_verify[0])):
             self.logger.warning(
                 """
 *************************************************************************
@@ -545,7 +563,7 @@ def verify_system_packages(self, app: AppConfig):
                 f"""\
 Unable to build {app.app_name} due to missing system dependencies. Run:
 
-    sudo {' '.join(system_installer)} {' '.join(missing)}
+    sudo {" ".join(system_installer)} {" ".join(missing)}
 
 to install the missing dependencies, and re-run Briefcase.
 """
@@ -780,7 +798,7 @@ class LinuxSystemRunCommand(LinuxSystemPassiveMixin, RunCommand):
     supported_host_os_reason = "Linux system projects can only be executed on Linux."
 
     def run_app(
-        self, app: AppConfig, test_mode: bool, passthrough: List[str], **kwargs
+        self, app: AppConfig, test_mode: bool, passthrough: list[str], **kwargs
     ):
         """Start the application.
 
@@ -830,27 +848,25 @@ class LinuxSystemPackageCommand(LinuxSystemMixin, PackageCommand):
     def packaging_formats(self):
         return ["deb", "rpm", "pkg", "system"]
 
-    def _verify_deb_tools(self):
-        """Verify that the local environment contains the debian packaging tools."""
-        if not Path("/usr/bin/dpkg-deb").exists():
-            raise BriefcaseCommandError(
-                "Can't find the dpkg tools. Try running `sudo apt install dpkg-dev`."
-            )
-
-    def _verify_rpm_tools(self):
-        """Verify that the local environment contains the redhat packaging tools."""
-        if not Path("/usr/bin/rpmbuild").exists():
-            raise BriefcaseCommandError(
-                "Can't find the rpm-build tools. Try running `sudo dnf install rpm-build`."
-            )
+    def _verify_packaging_tools(self, app: AppConfig):
+        """Verify that the local environment contains the packaging tools."""
+        tool_name, executable_name, package_name = {
+            "deb": ("dpkg", "dpkg-deb", "dpkg-dev"),
+            "rpm": ("rpm-build", "rpmbuild", "rpmbuild"),
+            "pkg": ("makepkg", "makepkg", "pacman"),
+        }[app.packaging_format]
 
-    def _verify_pkg_tools(self):
-        """Verify that the local environment contains the arch packaging tools(ABS)."""
-        if not Path("/usr/bin/makepkg").exists():
-            raise BriefcaseCommandError(
-                "Can't find the `makepkg` tool. Try running `sudo pacman -Syu pacman`."
-                # makepkg is part of pacman package
-            )
+        if not self.tools.shutil.which(executable_name):
+            if install_cmd := self._system_requirement_tools(app)[2]:
+                raise BriefcaseCommandError(
+                    f"Can't find the {tool_name} tools. "
+                    f"Try running `sudo {' '.join(install_cmd)} {package_name}`."
+                )
+            else:
+                raise BriefcaseCommandError(
+                    f"Can't find the {executable_name} tool. "
+                    f"Install this first to package the {app.packaging_format}."
+                )
 
     def verify_app_tools(self, app):
         super().verify_app_tools(app)
@@ -871,8 +887,7 @@ def verify_app_tools(self, app):
             )
 
         if not self.use_docker:
-            # Check for the format-specific packaging tools.
-            getattr(self, f"_verify_{app.packaging_format}_tools")()
+            self._verify_packaging_tools(app)
 
     def package_app(self, app: AppConfig, **kwargs):
         if app.packaging_format == "deb":


Test patch:
diff --git a/tests/commands/new/test_build_context.py b/tests/commands/new/test_build_context.py
index deacf4843..e6c668ed0 100644
--- a/tests/commands/new/test_build_context.py
+++ b/tests/commands/new/test_build_context.py
@@ -162,7 +162,7 @@ def main():
     # Needed to provide GTK
     "gtk3",
     # Needed to support Python bindings to GTK
-    "gobject-introspection", "typelib(Gtk)=3.0",
+    "gobject-introspection", "typelib(Gtk) = 3.0",
     # Dependencies that GTK looks for at runtime
     "libcanberra-gtk3-0",
     # Needed to provide WebKit2 at runtime
@@ -1159,7 +1159,7 @@ def main():
     # Needed to provide GTK
     "gtk3",
     # Needed to support Python bindings to GTK
-    "gobject-introspection", "typelib(Gtk)=3.0",
+    "gobject-introspection", "typelib(Gtk) = 3.0",
     # Dependencies that GTK looks for at runtime
     "libcanberra-gtk3-0",
     # Needed to provide WebKit2 at runtime
diff --git a/tests/platforms/linux/system/test_mixin__properties.py b/tests/platforms/linux/system/test_mixin__properties.py
index e2597dea5..230b48209 100644
--- a/tests/platforms/linux/system/test_mixin__properties.py
+++ b/tests/platforms/linux/system/test_mixin__properties.py
@@ -80,17 +80,19 @@ def test_binary_path(create_command, first_app_config, tmp_path):
 
 
 @pytest.mark.parametrize(
-    "packaging_format, filename",
+    "packaging_format, vendor_base, filename",
     [
-        ("deb", "first-app_0.0.1-1~somevendor-surprising_wonky.deb"),
-        ("rpm", "first-app-0.0.1-1.elsurprising.wonky.rpm"),
-        ("pkg", "first-app-0.0.1-1-wonky.pkg.tar.zst"),
+        ("deb", "debian", "first-app_0.0.1-1~somevendor-surprising_wonky.deb"),
+        ("rpm", "fedora", "first-app-0.0.1-1.elsurprising.wonky.rpm"),
+        ("rpm", "suse", "first-app-0.0.1-1.wonky.rpm"),
+        ("pkg", "arch", "first-app-0.0.1-1-wonky.pkg.tar.zst"),
     ],
 )
 def test_distribution_path(
     create_command,
     first_app_config,
     packaging_format,
+    vendor_base,
     filename,
     tmp_path,
 ):
@@ -98,6 +100,9 @@ def test_distribution_path(
     # Mock return value for ABI from packaging system
     create_command._build_env_abi = MagicMock(return_value="wonky")
 
+    # Set vendor base (RPM package naming changes for openSUSE)
+    first_app_config.target_vendor_base = vendor_base
+
     # Force a dummy vendor:codename for test purposes.
     first_app_config.target_vendor = "somevendor"
     first_app_config.target_codename = "surprising"
diff --git a/tests/platforms/linux/system/test_mixin__verify_system_packages.py b/tests/platforms/linux/system/test_mixin__verify_system_packages.py
index 715cab779..53b8fe8ba 100644
--- a/tests/platforms/linux/system/test_mixin__verify_system_packages.py
+++ b/tests/platforms/linux/system/test_mixin__verify_system_packages.py
@@ -20,6 +20,9 @@ def build_command(tmp_path, first_app):
     command.tools.host_os = "Linux"
     command.tools.host_arch = "wonky"
 
+    # All calls to `shutil.which()` succeed
+    command.tools.shutil.which = MagicMock(return_value="/path/to/exe")
+
     # Mock subprocess
     command.tools.subprocess = MagicMock()
 
@@ -124,6 +127,21 @@ def test_missing_packages(build_command, first_app_config, capsys):
         build_command.verify_system_packages(first_app_config)
 
 
+def test_missing_system_verify(build_command, first_app_config, capsys):
+    """If the program to verify system packages doesn't exist, a warning is logged."""
+    # Mock the system verifier is missing
+    build_command.tools.shutil.which = MagicMock(return_value="")
+
+    build_command.verify_system_packages(first_app_config)
+
+    # No packages verified
+    build_command.tools.subprocess.check_output.assert_not_called()
+
+    # A warning was logged.
+    output = capsys.readouterr().out
+    assert "WARNING: Can't verify system packages" in output
+
+
 def test_packages_installed(build_command, first_app_config, capsys):
     """If all required packages are installed, no error is raised."""
     # Mock the system requirement tools; there's a base requirement of
diff --git a/tests/platforms/linux/system/test_package.py b/tests/platforms/linux/system/test_package.py
index eae75a7d0..02e32f461 100644
--- a/tests/platforms/linux/system/test_package.py
+++ b/tests/platforms/linux/system/test_package.py
@@ -27,9 +27,7 @@ def package_command(monkeypatch, first_app, tmp_path):
     command.verify_system_packages = mock.MagicMock()
 
     # Mock the packaging tools.
-    command._verify_deb_tools = mock.MagicMock()
-    command._verify_rpm_tools = mock.MagicMock()
-    command._verify_pkg_tools = mock.MagicMock()
+    command._verify_packaging_tools = mock.MagicMock()
 
     return command
 
diff --git a/tests/platforms/linux/system/test_package__deb.py b/tests/platforms/linux/system/test_package__deb.py
index 1bb7c45ee..79b203af1 100644
--- a/tests/platforms/linux/system/test_package__deb.py
+++ b/tests/platforms/linux/system/test_package__deb.py
@@ -58,41 +58,49 @@ def test_verify_no_docker(monkeypatch, package_command, first_app_deb):
     # Mock not using docker
     package_command.target_image = None
 
-    # Mock the path of dpkg-deb
-    dpkg_deb = mock.MagicMock()
-    dpkg_deb.exists.return_value = True
-
-    mock_Path = mock.MagicMock(return_value=dpkg_deb)
-    monkeypatch.setattr(system, "Path", mock_Path)
+    # Mock the existence of dpkg-deb
+    package_command.tools.shutil.which = mock.MagicMock(return_value="/mybin/dpkg-deb")
 
     # App tools can be verified
     package_command.verify_app_tools(first_app_deb)
 
     # dpkg_deb was inspected
-    dpkg_deb.exists.assert_called_once()
+    package_command.tools.shutil.which.assert_called_once_with("dpkg-deb")
 
 
-def test_verify_dpkg_deb_missing(monkeypatch, package_command, first_app_deb):
-    """If dpkg_deb isn't installed, an error is raised."""
+@pytest.mark.parametrize(
+    "vendor_base, error_msg",
+    [
+        (
+            "debian",
+            "Can't find the dpkg tools. Try running `sudo apt install dpkg-dev`.",
+        ),
+        (None, "Can't find the dpkg-deb tool. Install this first to package the deb."),
+    ],
+)
+def test_verify_dpkg_deb_missing(
+    monkeypatch,
+    package_command,
+    first_app_deb,
+    vendor_base,
+    error_msg,
+):
+    """If dpkg-deb isn't installed, an error is raised."""
+    # Mock distro so packager is found or not appropriately
+    first_app_deb.target_vendor_base = vendor_base
+
+    # Mock packager as missing
+    package_command.tools.shutil.which = mock.MagicMock(return_value="")
+
     # Mock not using docker
     package_command.target_image = None
 
-    # Mock the path of dpkg-deb
-    dpkg_deb = mock.MagicMock()
-    dpkg_deb.exists.return_value = False
-
-    mock_Path = mock.MagicMock(return_value=dpkg_deb)
-    monkeypatch.setattr(system, "Path", mock_Path)
-
     # Verifying app tools will raise an error
-    with pytest.raises(
-        BriefcaseCommandError,
-        match=r"Can't find the dpkg tools. Try running `sudo apt install dpkg-dev`.",
-    ):
+    with pytest.raises(BriefcaseCommandError, match=error_msg):
         package_command.verify_app_tools(first_app_deb)
 
-    # dpkg_deb was inspected
-    dpkg_deb.exists.assert_called_once()
+    # which was called for dpkg-deb
+    package_command.tools.shutil.which.assert_called_once_with("dpkg-deb")
 
 
 def test_verify_docker(monkeypatch, package_command, first_app_deb):
diff --git a/tests/platforms/linux/system/test_package__pkg.py b/tests/platforms/linux/system/test_package__pkg.py
index 5124db05d..83a9a3505 100644
--- a/tests/platforms/linux/system/test_package__pkg.py
+++ b/tests/platforms/linux/system/test_package__pkg.py
@@ -86,41 +86,49 @@ def test_verify_no_docker(monkeypatch, package_command, first_app_pkg):
     # Mock not using docker
     package_command.target_image = None
 
-    # Mock the path of makepkg
-    makepkg = mock.MagicMock()
-    makepkg.exists.return_value = True
-
-    mock_Path = mock.MagicMock(return_value=makepkg)
-    monkeypatch.setattr(system, "Path", mock_Path)
+    # Mock the existence of makepkg
+    package_command.tools.shutil.which = mock.MagicMock(return_value="/mybin/makepkg")
 
     # App tools can be verified
     package_command.verify_app_tools(first_app_pkg)
 
     # makepkg was inspected
-    makepkg.exists.assert_called_once()
+    package_command.tools.shutil.which.assert_called_once_with("makepkg")
+
+
+@pytest.mark.parametrize(
+    "vendor_base, error_msg",
+    [
+        (
+            "arch",
+            "Can't find the makepkg tools. Try running `sudo pacman -Syu pacman`.",
+        ),
+        (None, "Can't find the makepkg tool. Install this first to package the pkg."),
+    ],
+)
+def test_verify_makepkg_missing(
+    monkeypatch,
+    package_command,
+    first_app_pkg,
+    vendor_base,
+    error_msg,
+):
+    """If makepkg isn't installed, an error is raised."""
+    # Mock distro so packager is found or not appropriately
+    first_app_pkg.target_vendor_base = vendor_base
 
+    # Mock packager as missing
+    package_command.tools.shutil.which = mock.MagicMock(return_value="")
 
-def test_verify_makepkg_missing(monkeypatch, package_command, first_app_pkg):
-    """If makepkg isn't installed, an error is raised."""
     # Mock not using docker
     package_command.target_image = None
 
-    # Mock the path of makepkg
-    makepkg = mock.MagicMock()
-    makepkg.exists.return_value = False
-
-    mock_Path = mock.MagicMock(return_value=makepkg)
-    monkeypatch.setattr(system, "Path", mock_Path)
-
     # Verifying app tools will raise an error
-    with pytest.raises(
-        BriefcaseCommandError,
-        match=r"Can't find the `makepkg` tool. Try running `sudo pacman -Syu pacman`.",
-    ):
+    with pytest.raises(BriefcaseCommandError, match=error_msg):
         package_command.verify_app_tools(first_app_pkg)
 
-    # makepkg was inspected
-    makepkg.exists.assert_called_once()
+    # which was called for makepkg
+    package_command.tools.shutil.which.assert_called_once_with("makepkg")
 
 
 def test_verify_docker(monkeypatch, package_command, first_app_pkg):
diff --git a/tests/platforms/linux/system/test_package__rpm.py b/tests/platforms/linux/system/test_package__rpm.py
index 457bd850e..4b51f2af2 100644
--- a/tests/platforms/linux/system/test_package__rpm.py
+++ b/tests/platforms/linux/system/test_package__rpm.py
@@ -49,7 +49,7 @@ def package_command(first_app, tmp_path):
 def first_app_rpm(first_app, tmp_path):
     # Mock a Red Hat app
     first_app.python_version_tag = "3"
-    first_app.target_vendor_base = "redhat"
+    first_app.target_vendor_base = "rhel"
     first_app.packaging_format = "rpm"
     first_app.glibc_version = "2.99"
     first_app.long_description = "Long description\nfor the app"
@@ -84,41 +84,49 @@ def test_verify_no_docker(monkeypatch, package_command, first_app_rpm):
     # Mock not using docker
     package_command.target_image = None
 
-    # Mock the path of rpmbuild
-    rpmbuild = mock.MagicMock()
-    rpmbuild.exists.return_value = True
-
-    mock_Path = mock.MagicMock(return_value=rpmbuild)
-    monkeypatch.setattr(system, "Path", mock_Path)
+    # Mock the existence of rpmbuild
+    package_command.tools.shutil.which = mock.MagicMock(return_value="/mybin/rpmbuild")
 
     # App tools can be verified
     package_command.verify_app_tools(first_app_rpm)
 
     # rpmbuild was inspected
-    rpmbuild.exists.assert_called_once()
+    package_command.tools.shutil.which.assert_called_once_with("rpmbuild")
+
+
+@pytest.mark.parametrize(
+    "vendor_base, error_msg",
+    [
+        (
+            "rhel",
+            "Can't find the rpm-build tools. Try running `sudo dnf install rpmbuild`.",
+        ),
+        (None, "Can't find the rpmbuild tool. Install this first to package the rpm."),
+    ],
+)
+def test_verify_rpmbuild_missing(
+    monkeypatch,
+    package_command,
+    first_app_rpm,
+    vendor_base,
+    error_msg,
+):
+    """If rpmbuild isn't installed, an error is raised."""
+    # Mock distro so packager is found or not appropriately
+    first_app_rpm.target_vendor_base = vendor_base
 
+    # Mock packager as missing
+    package_command.tools.shutil.which = mock.MagicMock(return_value="")
 
-def test_verify_rpmbuild_missing(monkeypatch, package_command, first_app_rpm):
-    """If rpmbuild isn't installed, an error is raised."""
     # Mock not using docker
     package_command.target_image = None
 
-    # Mock the path of rpmbuild
-    rpmbuild = mock.MagicMock()
-    rpmbuild.exists.return_value = False
-
-    mock_Path = mock.MagicMock(return_value=rpmbuild)
-    monkeypatch.setattr(system, "Path", mock_Path)
-
     # Verifying app tools will raise an error
-    with pytest.raises(
-        BriefcaseCommandError,
-        match=r"Can't find the rpm-build tools. Try running `sudo dnf install rpm-build`.",
-    ):
+    with pytest.raises(BriefcaseCommandError, match=error_msg):
         package_command.verify_app_tools(first_app_rpm)
 
     # rpmbuild was inspected
-    rpmbuild.exists.assert_called_once()
+    package_command.tools.shutil.which.assert_called_once_with("rpmbuild")
 
 
 def test_verify_docker(monkeypatch, package_command, first_app_rpm):