Repository: beeware/briefcase (python)

Issue:
Install a python requirement with an --extra-index-url argument
### What is the problem or limitation you are having?

Hi, I have a project which requires PyTorch CPU version, not the default GPU version. Normally, it's possible to install this in one of two ways using pip:
```
pip install torch torchvision --extra-index-url https://download.pytorch.org/whl/cpu
```
or
```
pip install -r requirements.txt
````
where the requirements look like:
```
--extra-index-url https://download.pytorch.org/whl/cpu
torch==2.0.1
torchvision==0.15.2
```

### Describe the solution you'd like

How do I translate this to the Briefcase environment? This is a question rather than a solution, but I'm new to Beeware and don't see any obvious way of including `--extra-index-url` in the `requires` section of the `pyproject.toml` file.

### Describe alternatives you've considered

Thanks!

### Additional context

_No response_

Pull request:
Add per-app pip install argument configuration
<!--- Describe your changes in detail -->
<!--- What problem does this change solve? -->
<!--- If this PR relates to an issue, include Refs #XXX or Fixes #XXX -->
Fixes #1270 

This PR adds a new app config option `requirement_installer_args`, which is a list of strings, as discussed in the issue.

The goal is to allow users to configure `pip install` with any options they want. As discussed in the issue, arguments that appear to be local paths in format and resolve to an existing local path relative to the command's base path are transformed to absolute paths when calling `pip install`.

For most build targets, the arguments are added directly to the `pip install` invocation, alongside the requirements. Users have full control over the options they choose to pass.

For some build targets (Flatpak and Android), Briefcase hands off `pip install` invocation to a specialised build tool (Flatpak builder and Chaquo respectively). In these cases, Briefcase must supply the user-defined installer arguments in a way the build tool can reference. For both these cases, I've updated the templates (https://github.com/beeware/briefcase-linux-flatpak-template/pull/53, https://github.com/beeware/briefcase-android-gradle-template/pull/95) to process a `pip-options.txt` file defined in the path index as `app_requirement_installer_args_path`. When this path is defined, Briefcase will write a new-line separated list of the requirement installer args. It is up to the template to decide how to utilise this file. If no such path is defined, Briefcase will not write the installer args, but will still write the `requirements.txt` as it does today. This ensures seamless backwards compatibility with templates that do not define the new key in the path index.

The PR also changes the generated `requirements.txt` file to always write the timestamp comment, even if the file would end up empty otherwise.

## 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/automation/pyproject.toml b/automation/pyproject.toml
index afdb33e2..d9127ad9 100644
--- a/automation/pyproject.toml
+++ b/automation/pyproject.toml
@@ -1,7 +1,7 @@
 [build-system]
 requires = [
     # keep versions in sync with ../pyproject.toml
-    "setuptools==75.3.0",
+    "setuptools==75.2.0",
     "setuptools_scm==8.1.0",
     "setuptools_dynamic_dependencies @ git+https://github.com/beeware/setuptools_dynamic_dependencies",
 ]
diff --git a/changes/1270.feature.rst b/changes/1270.feature.rst
new file mode 100644
index 00000000..8d8170d5
--- /dev/null
+++ b/changes/1270.feature.rst
@@ -0,0 +1,1 @@
+Briefcase now supports per-app configuration of ``pip install`` command line arguments using ``requirement_installer_args``.
diff --git a/changes/2056.misc.rst b/changes/2056.misc.rst
deleted file mode 100644
index 08cd276c..00000000
--- a/changes/2056.misc.rst
+++ /dev/null
@@ -1,1 +0,0 @@
-The pinned dependencies in pyproject.toml and tox.ini were updated to their latest versions.
diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst
index 43b9fe1a..e7d12aac 100644
--- a/docs/reference/configuration.rst
+++ b/docs/reference/configuration.rst
@@ -309,6 +309,59 @@ multiple paragraphs, if necessary. The long description *must not* be a copy of
 the ``description``, or include the ``description`` as the first line of the
 ``long_description``.
 
+``requirement_installer_args``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+A list of strings of arguments to pass to the requirement installer when building the
+app.
+
+Strings will be automatically transformed to absolute paths if they appear to be
+relative paths (i.e., starting with ``./`` or ``../``) and resolve to an existing path
+relative to the app's configuration file. This is done to support build targets where
+the requirement installer command does not run with the same working directory as the
+configuration file.
+
+If you encounter a false-positive and need to prevent this transformation,
+you may do so by using a single string for the argument name and the value.
+Arguments starting with ``-`` will never be transformed, even if they happen to resolve
+to an existing path relative to the configuration file.
+
+The following examples will have the relative path transformed to an absolute one when
+Briefcase runs the requirement installation command if the path ``wheels`` exists
+relative to the configuration file:
+
+.. code-block:: TOML
+
+    requirement_installer_args = ["--find-links", "./wheels"]
+
+    requirement_installer_args = ["-f", "../wheels"]
+
+On the other hand, the next two examples avoid it because the string starts with ``-``,
+does not start with a relative path indication (``./`` or ``../``), or do not resolve
+to an existing path:
+
+.. code-block:: TOML
+
+    requirement_installer_args = ["-f./wheels"]
+
+    requirement_installer_args = ["--find-links=./wheels"]
+
+    requirement_installer_args = ["-f", "wheels"]
+
+    requirement_installer_args = ["-f", "./this/path/does/not/exist"]
+
+.. admonition:: Supported arguments
+
+    The arguments supported in ``requirement_installer_args`` depend on the requirement
+    installer backend.
+
+    The only currently supported requirement installer is ``pip``. As such, the list
+    should only contain valid
+    arguments to the ``pip install`` command.
+
+    Briefcase does not validate the inputs to this configuration, and will only report
+    errors directly indicated by the requirement installer backend.
+
 ``primary_color``
 ~~~~~~~~~~~~~~~~~
 
diff --git a/docs/reference/platforms/linux/system.rst b/docs/reference/platforms/linux/system.rst
index 66cb6231..a368f89f 100644
--- a/docs/reference/platforms/linux/system.rst
+++ b/docs/reference/platforms/linux/system.rst
@@ -238,3 +238,15 @@ perform container setup operations as ``root``, switch the container's user to
 
     USER brutus
     """
+
+Platform quirks
+===============
+
+Local path references and Docker builds
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Docker builds are not able to reference local paths in the ``requires`` and
+``requirement_installer_args`` configurations. This is because the Docker container only
+has access to specific file paths on the host system. See `issue #2018
+<https://github.com/beeware/briefcase/issues/2081>`__ for more discussion of the
+problem, and some possible workarounds.
diff --git a/pyproject.toml b/pyproject.toml
index fdb5fd34..60e7a885 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,7 +1,7 @@
 [build-system]
 requires = [
     # keep versions in sync with automation/pyproject.toml
-    "setuptools==75.3.0",
+    "setuptools==75.2.0",
     "setuptools_scm==8.1.0",
 ]
 build-backend = "setuptools.build_meta"
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index 94e34bd7..418826f2 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -499,6 +499,17 @@ a custom location for Briefcase's tools.
         """
         return self.bundle_path(app) / self.path_index(app, "app_requirements_path")
 
+    def app_requirement_installer_args_path(self, app: AppConfig) -> Path:
+        """Obtain the path into which a newline delimited requirement installer args
+        file should be written.
+
+        :param app: The config object for the app
+        :return: The full path where the file should be written
+        """
+        return self.bundle_path(app) / self.path_index(
+            app, "app_requirement_installer_args_path"
+        )
+
     def app_packages_path(self, app: AppConfig) -> Path:
         """Obtain the path into which requirements should be installed.
 
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index 5324bc36..32bbf4d4 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import hashlib
 import os
 import platform
+import re
 import shutil
 import subprocess
 import sys
@@ -504,6 +505,7 @@ class CreateCommand(BaseCommand):
         app: AppConfig,
         requires: list[str],
         requirements_path: Path,
+        requirement_installer_args_path: Path | None,
     ):
         """Configure application requirements by writing a requirements.txt file.
 
@@ -511,24 +513,34 @@ class CreateCommand(BaseCommand):
         :param requires: The full list of requirements
         :param requirements_path: The full path to a requirements.txt file that will be
             written.
+        :param requirement_installer_args_path: The full path to where newline
+            delimited additional requirement installer argumentss should be written if
+            the template supports it.
         """
 
         with self.input.wait_bar("Writing requirements file..."):
             with requirements_path.open("w", encoding="utf-8") as f:
+                # Add timestamp so build systems (such as Gradle) detect a change
+                # in the file and perform a re-installation of all requirements.
+                f.write(f"# Generated {datetime.now()}\n")
+
                 if requires:
-                    # Add timestamp so build systems (such as Gradle) detect a change
-                    # in the file and perform a re-installation of all requirements.
-                    f.write(f"# Generated {datetime.now()}\n")
                     for requirement in requires:
                         # If the requirement is a local path, convert it to
                         # absolute, because Flatpak moves the requirements file
                         # to a different place before using it.
-                        if _is_local_requirement(requirement):
+                        if _is_local_path(requirement):
                             # We use os.path.abspath() rather than Path.resolve()
                             # because we *don't* want Path's symlink resolving behavior.
                             requirement = os.path.abspath(self.base_path / requirement)
                         f.write(f"{requirement}\n")
 
+            if requirement_installer_args_path:
+                pip_args = "\n".join(self._extra_pip_args(app))
+                requirement_installer_args_path.write_text(
+                    f"{pip_args}\n", encoding="utf-8"
+                )
+
     def _pip_requires(self, app: AppConfig, requires: list[str]):
         """Convert the list of requirements to be passed to pip into its final form.
 
@@ -544,7 +556,17 @@ class CreateCommand(BaseCommand):
         :param app: The app configuration
         :returns: A list of additional arguments
         """
-        return []
+        args: list[str] = []
+        for argument in app.requirement_installer_args:
+            to_append = argument
+            if relative_path_matcher.match(argument) and _is_local_path(argument):
+                abs_path = os.path.abspath(self.base_path / argument)
+                if Path(abs_path).exists():
+                    to_append = abs_path
+
+            args.append(to_append)
+
+        return args
 
     def _pip_install(
         self,
@@ -652,8 +674,21 @@ class CreateCommand(BaseCommand):
 
         try:
             requirements_path = self.app_requirements_path(app)
-            self._write_requirements_file(app, requires, requirements_path)
         except KeyError:
+            requirements_path = None
+
+        try:
+            requirement_installer_args_path = self.app_requirement_installer_args_path(
+                app
+            )
+        except KeyError:
+            requirement_installer_args_path = None
+
+        if requirements_path:
+            self._write_requirements_file(
+                app, requires, requirements_path, requirement_installer_args_path
+            )
+        else:
             try:
                 app_packages_path = self.app_packages_path(app)
                 self._install_app_requirements(app, requires, app_packages_path)
@@ -972,15 +1007,18 @@ def _has_url(requirement):
     )
 
 
-def _is_local_requirement(requirement):
-    """Determine if the requirement is a local file path.
+def _is_local_path(reference):
+    """Determine if the reference is a local file path.
 
-    :param requirement: The requirement to check
-    :returns: True if the requirement is a local file path
+    :param reference: The reference to check
+    :returns: True if the reference is a local file path
     """
-    # Windows allows both / and \ as a path separator in requirements.
+    # Windows allows both / and \ as a path separator in references.
     separators = [os.sep]
     if os.altsep:
         separators.append(os.altsep)
 
-    return any(sep in requirement for sep in separators) and (not _has_url(requirement))
+    return any(sep in reference for sep in separators) and (not _has_url(reference))
+
+
+relative_path_matcher = re.compile(r"^\.{1,2}[\\/]")
diff --git a/src/briefcase/commands/dev.py b/src/briefcase/commands/dev.py
index 066eb8e1..89b74733 100644
--- a/src/briefcase/commands/dev.py
+++ b/src/briefcase/commands/dev.py
@@ -100,7 +100,8 @@ class DevCommand(RunAppMixin, BaseCommand):
                             "--upgrade",
                         ]
                         + (["-vv"] if self.logger.is_deep_debug else [])
-                        + requires,
+                        + requires
+                        + app.requirement_installer_args,
                         check=True,
                         encoding="UTF-8",
                     )
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 632268ec..09a44842 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -266,6 +266,7 @@ class AppConfig(BaseConfig):
         supported=True,
         long_description=None,
         console_app=False,
+        requirement_installer_args: list[str] | None = None,
         **kwargs,
     ):
         super().__init__(**kwargs)
@@ -295,6 +296,9 @@ class AppConfig(BaseConfig):
         self.long_description = long_description
         self.license = license
         self.console_app = console_app
+        self.requirement_installer_args = (
+            [] if requirement_installer_args is None else requirement_installer_args
+        )
 
         if not is_valid_app_name(self.app_name):
             raise BriefcaseConfigError(
diff --git a/src/briefcase/platforms/iOS/xcode.py b/src/briefcase/platforms/iOS/xcode.py
index e0cab025..d74f3f4e 100644
--- a/src/briefcase/platforms/iOS/xcode.py
+++ b/src/briefcase/platforms/iOS/xcode.py
@@ -305,7 +305,7 @@ class iOSXcodeCreateCommand(iOSXcodePassiveMixin, CreateCommand):
         :param app: The app configuration
         :returns: A list of additional arguments
         """
-        return [
+        return super()._extra_pip_args(app) + [
             "--prefer-binary",
             "--extra-index-url",
             "https://pypi.anaconda.org/beeware/simple",
diff --git a/src/briefcase/platforms/linux/__init__.py b/src/briefcase/platforms/linux/__init__.py
index ee467475..93082bc6 100644
--- a/src/briefcase/platforms/linux/__init__.py
+++ b/src/briefcase/platforms/linux/__init__.py
@@ -4,7 +4,7 @@ import subprocess
 import sys
 from pathlib import Path
 
-from briefcase.commands.create import _is_local_requirement
+from briefcase.commands.create import _is_local_path
 from briefcase.commands.open import OpenCommand
 from briefcase.config import AppConfig
 from briefcase.exceptions import BriefcaseCommandError, ParseError
@@ -156,7 +156,7 @@ class LocalRequirementsMixin:  # pragma: no-cover-if-is-windows
 
         # Iterate over every requirement, looking for local references
         for requirement in requires:
-            if _is_local_requirement(requirement):
+            if _is_local_path(requirement):
                 if Path(requirement).is_dir():
                     # Requirement is a filesystem reference
                     # Build an sdist for the local requirement
@@ -210,7 +210,7 @@ class LocalRequirementsMixin:  # pragma: no-cover-if-is-windows
         final = [
             requirement
             for requirement in super()._pip_requires(app, requires)
-            if not _is_local_requirement(requirement)
+            if not _is_local_path(requirement)
         ]
 
         # Add in any local packages.


Test patch:
diff --git a/tests/commands/create/conftest.py b/tests/commands/create/conftest.py
index 5caf112c..0e7d4ed3 100644
--- a/tests/commands/create/conftest.py
+++ b/tests/commands/create/conftest.py
@@ -274,6 +274,21 @@ def app_requirements_path_index(bundle_path):
         tomli_w.dump(index, f)
 
 
+@pytest.fixture
+def app_requirement_installer_args_path_index(bundle_path):
+    with (bundle_path / "briefcase.toml").open("wb") as f:
+        index = {
+            "paths": {
+                "app_path": "path/to/app",
+                "app_requirements_path": "path/to/requirements.txt",
+                "app_requirement_installer_args_path": "path/to/installer-args.txt",
+                "support_path": "path/to/support",
+                "support_revision": 37,
+            }
+        }
+        tomli_w.dump(index, f)
+
+
 @pytest.fixture
 def no_support_revision_index(bundle_path):
     with (bundle_path / "briefcase.toml").open("wb") as f:
@@ -309,6 +324,11 @@ def app_requirements_path(bundle_path):
     return bundle_path / "path/to/requirements.txt"
 
 
+@pytest.fixture
+def app_requirement_installer_args_path(bundle_path):
+    return bundle_path / "path/to/installer-args.txt"
+
+
 @pytest.fixture
 def app_packages_path(bundle_path):
     return bundle_path / "path/to/app_packages"
diff --git a/tests/commands/create/test_generate_app_template.py b/tests/commands/create/test_generate_app_template.py
index 8c05bae0..7b2abc6f 100644
--- a/tests/commands/create/test_generate_app_template.py
+++ b/tests/commands/create/test_generate_app_template.py
@@ -43,6 +43,7 @@ def full_context():
         "requests": {},
         "document_types": {},
         "license": {"file": "LICENSE"},
+        "requirement_installer_args": [],
         # Properties of the generating environment
         "python_version": platform.python_version(),
         "host_arch": "gothic",
diff --git a/tests/commands/create/test_install_app_requirements.py b/tests/commands/create/test_install_app_requirements.py
index 51ecf579..280c191f 100644
--- a/tests/commands/create/test_install_app_requirements.py
+++ b/tests/commands/create/test_install_app_requirements.py
@@ -8,7 +8,7 @@ import pytest
 import tomli_w
 
 import briefcase
-from briefcase.commands.create import _is_local_requirement
+from briefcase.commands.create import _is_local_path
 from briefcase.console import LogLevel
 from briefcase.exceptions import BriefcaseCommandError, RequirementsInstallError
 from briefcase.integrations.subprocess import Subprocess
@@ -162,6 +162,197 @@ def test_app_packages_valid_requires(
     assert myapp.test_requires is None
 
 
+def test_app_packages_requirement_installer_args_no_paths(
+    create_command,
+    myapp,
+    app_packages_path,
+    app_path,
+    app_packages_path_index,
+):
+    """If an app has requirement installer arguments, they are used in pip install."""
+    (create_command.base_path / "packages").mkdir(exist_ok=True)
+    myapp.requirement_installer_args = ["--no-cache"]
+    myapp.requires = ["package"]
+
+    create_command.install_app_requirements(myapp, test_mode=False)
+
+    # A request was made to install requirements
+    create_command.tools[myapp].app_context.run.assert_called_with(
+        [
+            sys.executable,
+            "-u",
+            "-X",
+            "utf8",
+            "-m",
+            "pip",
+            "install",
+            "--disable-pip-version-check",
+            "--no-python-version-warning",
+            "--upgrade",
+            "--no-user",
+            f"--target={app_packages_path}",
+            "--no-cache",
+            "package",
+        ],
+        check=True,
+        encoding="UTF-8",
+    )
+
+
+def test_app_packages_requirement_installer_args_path_transformed(
+    create_command,
+    myapp,
+    app_packages_path,
+    app_path,
+    app_packages_path_index,
+):
+    """If an app's requirement installer arguments includes a relative path that exists,
+    it is transformed to an absolute path."""
+    (create_command.base_path / "packages").mkdir(exist_ok=True)
+    myapp.requirement_installer_args = ["--extra-index-url", "./packages"]
+    myapp.requires = ["package"]
+
+    create_command.install_app_requirements(myapp, test_mode=False)
+
+    # A request was made to install requirements
+    create_command.tools[myapp].app_context.run.assert_called_with(
+        [
+            sys.executable,
+            "-u",
+            "-X",
+            "utf8",
+            "-m",
+            "pip",
+            "install",
+            "--disable-pip-version-check",
+            "--no-python-version-warning",
+            "--upgrade",
+            "--no-user",
+            f"--target={app_packages_path}",
+            "--extra-index-url",
+            os.path.abspath(create_command.base_path / "packages"),
+            "package",
+        ],
+        check=True,
+        encoding="UTF-8",
+    )
+
+
+def test_app_packages_requirement_installer_args_coincidental_path_not_transformed(
+    create_command,
+    myapp,
+    app_packages_path,
+    app_path,
+    app_packages_path_index,
+):
+    """If an app's requirement installer arguments includes a path that exists,
+    but it starts with `-`, it is used as-is."""
+    (create_command.base_path / "-f" / "wheels").mkdir(parents=True, exist_ok=True)
+    myapp.requirement_installer_args = ["-f./wheels"]
+    myapp.requires = ["package"]
+
+    create_command.install_app_requirements(myapp, test_mode=False)
+
+    # A request was made to install requirements
+    create_command.tools[myapp].app_context.run.assert_called_with(
+        [
+            sys.executable,
+            "-u",
+            "-X",
+            "utf8",
+            "-m",
+            "pip",
+            "install",
+            "--disable-pip-version-check",
+            "--no-python-version-warning",
+            "--upgrade",
+            "--no-user",
+            f"--target={app_packages_path}",
+            "-f./wheels",
+            "package",
+        ],
+        check=True,
+        encoding="UTF-8",
+    )
+
+
+def test_app_packages_requirement_installer_args_path_not_transformed(
+    create_command,
+    myapp,
+    app_packages_path,
+    app_path,
+    app_packages_path_index,
+):
+    """If an app's requirement installer arguments have an argument that looks like a local path,
+    but does not resolve to an existing path, it is used as-is."""
+    (create_command.base_path / "packages").unlink(missing_ok=True)
+    myapp.requirement_installer_args = ["--extra-index-url", "./packages"]
+    myapp.requires = ["package"]
+
+    create_command.install_app_requirements(myapp, test_mode=False)
+
+    # A request was made to install requirements
+    create_command.tools[myapp].app_context.run.assert_called_with(
+        [
+            sys.executable,
+            "-u",
+            "-X",
+            "utf8",
+            "-m",
+            "pip",
+            "install",
+            "--disable-pip-version-check",
+            "--no-python-version-warning",
+            "--upgrade",
+            "--no-user",
+            f"--target={app_packages_path}",
+            "--extra-index-url",
+            "./packages",
+            "package",
+        ],
+        check=True,
+        encoding="UTF-8",
+    )
+
+
+def test_app_packages_requirement_installer_args_combined_argument_not_transformed(
+    create_command,
+    myapp,
+    app_packages_path,
+    app_path,
+    app_packages_path_index,
+):
+    """If an app's requirement installer arguments use a combined argument,
+    paths are not modified."""
+    (create_command.base_path / "packages").mkdir(exist_ok=True)
+    myapp.requirement_installer_args = ["--extra-index-url=./packages"]
+    myapp.requires = ["package"]
+
+    create_command.install_app_requirements(myapp, test_mode=False)
+
+    # A request was made to install requirements
+    create_command.tools[myapp].app_context.run.assert_called_with(
+        [
+            sys.executable,
+            "-u",
+            "-X",
+            "utf8",
+            "-m",
+            "pip",
+            "install",
+            "--disable-pip-version-check",
+            "--no-python-version-warning",
+            "--upgrade",
+            "--no-user",
+            f"--target={app_packages_path}",
+            "--extra-index-url=./packages",
+            "package",
+        ],
+        check=True,
+        encoding="UTF-8",
+    )
+
+
 def test_app_packages_valid_requires_no_support_package(
     create_command,
     myapp,
@@ -427,6 +618,7 @@ def test_app_requirements_no_requires(
     myapp,
     app_requirements_path,
     app_requirements_path_index,
+    mock_now,
 ):
     """If an app has no requirements, a requirements file is still written."""
     myapp.requires = None
@@ -437,7 +629,7 @@ def test_app_requirements_no_requires(
     # requirements.txt doesn't exist either
     assert app_requirements_path.exists()
     with app_requirements_path.open(encoding="utf-8") as f:
-        assert f.read() == ""
+        assert f.read() == f"{GENERATED_DATETIME}\n"
 
     # Original app definitions haven't changed
     assert myapp.requires is None
@@ -449,6 +641,7 @@ def test_app_requirements_empty_requires(
     myapp,
     app_requirements_path,
     app_requirements_path_index,
+    mock_now,
 ):
     """If an app has an empty requirements list, a requirements file is still
     written."""
@@ -460,7 +653,7 @@ def test_app_requirements_empty_requires(
     # requirements.txt doesn't exist either
     assert app_requirements_path.exists()
     with app_requirements_path.open(encoding="utf-8") as f:
-        assert f.read() == ""
+        assert f.read() == f"{GENERATED_DATETIME}\n"
 
     # Original app definitions haven't changed
     assert myapp.requires == []
@@ -491,6 +684,130 @@ def test_app_requirements_requires(
     assert myapp.test_requires is None
 
 
+def test_app_requirements_requirement_installer_args_no_template_support(
+    create_command,
+    myapp,
+    app_path,
+    app_requirements_path,
+    mock_now,
+    app_requirements_path_index,
+):
+    """If an app has requirement install args, a requirements file is still written,
+    but no requirement installer args file is written if the template does not support it.
+    """
+    myapp.requirement_installer_args = ["--no-cache"]
+    myapp.requires = ["my-favourite-package"]
+
+    # Install requirements into the bundle
+    create_command.install_app_requirements(myapp, test_mode=False)
+
+    # requirements.txt exists either
+    assert app_requirements_path.exists()
+    with app_requirements_path.open(encoding="utf-8") as f:
+        assert f.read() == f"{GENERATED_DATETIME}\nmy-favourite-package\n"
+
+    # Original app definitions haven't changed
+    assert myapp.requires == ["my-favourite-package"]
+    assert myapp.test_requires is None
+
+
+def test_app_requirements_requirement_installer_args_with_template_support(
+    create_command,
+    myapp,
+    app_path,
+    app_requirements_path,
+    app_requirement_installer_args_path,
+    mock_now,
+    app_requirement_installer_args_path_index,
+):
+    """If an app has requirement install args, a requirements file is still written,
+    and requirement installer args file is written if the template supports it."""
+    myapp.requirement_installer_args = ["--no-cache", "-f", "wheels with space"]
+    myapp.requires = ["my-favourite-package"]
+
+    # Install requirements into the bundle
+    create_command.install_app_requirements(myapp, test_mode=False)
+
+    # requirements.txt exists either
+    assert app_requirements_path.exists()
+    with app_requirements_path.open(encoding="utf-8") as f:
+        assert f.read() == f"{GENERATED_DATETIME}\nmy-favourite-package\n"
+
+    assert app_requirement_installer_args_path.exists()
+    assert (
+        app_requirement_installer_args_path.read_text(encoding="utf-8")
+        == "--no-cache\n-f\nwheels with space\n"
+    )
+
+    # Original app definitions haven't changed
+    assert myapp.requires == ["my-favourite-package"]
+    assert myapp.test_requires is None
+
+
+def test_app_requirements_requirement_installer_args_without_requires_no_template_support(
+    create_command,
+    myapp,
+    app_path,
+    app_requirements_path,
+    app_requirement_installer_args_path,
+    mock_now,
+    app_requirements_path_index,
+):
+    """If an app has requirement install args and no requires,
+    a requirements file is still written with the timestamp,
+    and does not write a requirement installer args file."""
+    myapp.requirement_installer_args = ["--no-cache"]
+    myapp.requires = []
+
+    # Install requirements into the bundle
+    create_command.install_app_requirements(myapp, test_mode=False)
+
+    # requirements.txt exists either
+    assert app_requirements_path.exists()
+    with app_requirements_path.open(encoding="utf-8") as f:
+        assert f.read() == f"{GENERATED_DATETIME}\n"
+
+    assert not app_requirement_installer_args_path.exists()
+
+    # Original app definitions haven't changed
+    assert myapp.requires == []
+    assert myapp.test_requires is None
+
+
+def test_app_requirements_requirement_installer_args_without_requires_with_template_support(
+    create_command,
+    myapp,
+    app_path,
+    app_requirements_path,
+    app_requirement_installer_args_path,
+    mock_now,
+    app_requirement_installer_args_path_index,
+):
+    """If an app has requirement install args and no requires,
+    a requirements file is still written with the timestamp,
+    as well as requirement installer args file when the template supports it."""
+    myapp.requirement_installer_args = ["--no-cache", "-f", "wheels with space"]
+    myapp.requires = []
+
+    # Install requirements into the bundle
+    create_command.install_app_requirements(myapp, test_mode=False)
+
+    # requirements.txt exists either
+    assert app_requirements_path.exists()
+    with app_requirements_path.open(encoding="utf-8") as f:
+        assert f.read() == f"{GENERATED_DATETIME}\n"
+
+    assert app_requirement_installer_args_path.exists()
+    assert (
+        app_requirement_installer_args_path.read_text(encoding="utf-8")
+        == "--no-cache\n-f\nwheels with space\n"
+    )
+
+    # Original app definitions haven't changed
+    assert myapp.requires == []
+    assert myapp.test_requires is None
+
+
 @pytest.mark.parametrize(
     "altsep, requirement, expected",
     [
@@ -501,7 +818,7 @@ def test_app_requirements_requires(
         (">", "asdf+xcvb", False),
     ],
 )
-def test__is_local_requirement_altsep_respected(
+def test__is_local_path_altsep_respected(
     altsep,
     requirement,
     expected,
@@ -510,7 +827,7 @@ def test__is_local_requirement_altsep_respected(
     """``os.altsep`` is included as a separator when available."""
     monkeypatch.setattr(os, "sep", "/")
     monkeypatch.setattr(os, "altsep", altsep)
-    assert _is_local_requirement(requirement) is expected
+    assert _is_local_path(requirement) is expected
 
 
 def _test_app_requirements_paths(
diff --git a/tests/commands/dev/test_install_dev_requirements.py b/tests/commands/dev/test_install_dev_requirements.py
index a813a4cd..7156ef23 100644
--- a/tests/commands/dev/test_install_dev_requirements.py
+++ b/tests/commands/dev/test_install_dev_requirements.py
@@ -77,6 +77,17 @@ def test_no_requirements(dev_command, first_app):
     dev_command.tools.subprocess.run.assert_not_called()
 
 
+def test_no_requirements_with_requirement_installer_Args(dev_command, first_app):
+    """Ensure dependency installation is not attempted when nothing to install,
+    even if requirement installer args are defined."""
+    first_app.requires = []
+    first_app.requirement_installer_args = ["--no-cache"]
+
+    dev_command.install_dev_requirements(app=first_app)
+
+    dev_command.tools.subprocess.run.assert_not_called()
+
+
 def test_install_requirements_test_mode(dev_command, first_app):
     """If an app has test requirements, they are also installed."""
     first_app.requires = ["package-one", "package_two", "packagethree"]
@@ -128,3 +139,29 @@ def test_only_test_requirements(dev_command, first_app):
         check=True,
         encoding="UTF-8",
     )
+
+
+def test_requirement_installer_args(dev_command, first_app):
+    """If an app has requirement installer args, they're used correctly."""
+    first_app.requires = ["one", "two"]
+    first_app.requirement_installer_args = ["--no-cache"]
+
+    dev_command.install_dev_requirements(app=first_app)
+
+    dev_command.tools.subprocess.run.assert_called_once_with(
+        [
+            sys.executable,
+            "-u",
+            "-X",
+            "utf8",
+            "-m",
+            "pip",
+            "install",
+            "--upgrade",
+            "one",
+            "two",
+            "--no-cache",
+        ],
+        check=True,
+        encoding="UTF-8",
+    )