diff --git a/changes/2700.bugfix.md b/changes/2700.bugfix.md new file mode 100644 index 000000000..98dfc33da --- /dev/null +++ b/changes/2700.bugfix.md @@ -0,0 +1 @@ +Linux projects can now install extras on requirements that are specified as a reference to a local source directory. diff --git a/src/briefcase/platforms/linux/__init__.py b/src/briefcase/platforms/linux/__init__.py index f7aa54b3d..d15e6bdad 100644 --- a/src/briefcase/platforms/linux/__init__.py +++ b/src/briefcase/platforms/linux/__init__.py @@ -156,12 +156,21 @@ def _install_app_requirements( self.tools.os.mkdir(local_requirements_path) # Iterate over every requirement, looking for local references + localized_requires = [] for requirement in requires: if _is_local_path(requirement): - if Path(requirement).is_dir(): + parts = requirement.rsplit("[", 1) + req_name = parts[0] + try: + extras = f"[{parts[1]}" + except IndexError: + extras = "" + + local_req = (self.base_path / req_name).resolve() + if local_req.is_dir(): # Requirement is a filesystem reference - # Build an sdist for the local requirement - with self.console.wait_bar(f"Building sdist for {requirement}..."): + # Build a wheel for the local requirement + with self.console.wait_bar(f"Building wheels for {req_name}..."): try: self.tools.subprocess.check_output( [ @@ -170,57 +179,57 @@ def _install_app_requirements( "utf8", "-m", "build", - "--sdist", + "--wheel", "--outdir", local_requirements_path, - requirement, + local_req, ], encoding="UTF-8", ) + + # The newest file in the directory will be the wheel that + # was just created. + newest_file = max( + ( + f + for f in self.local_requirements_path(app).iterdir() + if f.is_file() + ), + key=lambda f: f.stat().st_mtime, + ) + + localized_requires.append(str(newest_file) + extras) + except subprocess.CalledProcessError as e: raise BriefcaseCommandError( - f"Unable to build sdist for {requirement}" + f"Unable to build wheel for {requirement}" ) from e else: try: # Requirement is an existing sdist or wheel file. - self.tools.shutil.copy(requirement, local_requirements_path) + self.tools.shutil.copy(local_req, local_requirements_path) + + # The requirement must be re-written as a local file reference + localized_requires.append( + str(self.local_requirements_path(app) / local_req.name) + + extras + ) + except OSError as e: raise BriefcaseCommandError( f"Unable to find local requirement {requirement}" ) from e + else: + # The requirement can be used as-is + localized_requires.append(requirement) # Continue with the default app requirement handling. return super()._install_app_requirements( app, - requires=requires, + requires=localized_requires, app_packages_path=app_packages_path, ) - def _pip_requires(self, app: AppConfig, requires: list[str]): - """Convert the requirements list to an .deb project compatible format. - - Any local file requirements are converted into a reference to the file generated - by _install_app_requirements(). - - :param app: The app configuration - :param requires: The user-specified list of app requirements - :returns: The final list of requirement arguments to pass to pip - """ - # Copy all the requirements that are non-local - final = [ - requirement - for requirement in super()._pip_requires(app, requires) - if not _is_local_path(requirement) - ] - - # Add in any local packages. - # The sort is needed to ensure testing consistency - for filename in sorted(self.local_requirements_path(app).iterdir()): - final.append(filename) - - return final - class DockerOpenCommand(OpenCommand): # pragma: no-cover-if-is-windows # A command that redirects Open to an interactive shell in the container diff --git a/tests/platforms/linux/test_LocalRequirementsMixin.py b/tests/platforms/linux/test_LocalRequirementsMixin.py index d9768937d..aa7455be3 100644 --- a/tests/platforms/linux/test_LocalRequirementsMixin.py +++ b/tests/platforms/linux/test_LocalRequirementsMixin.py @@ -251,6 +251,21 @@ def test_install_app_requirements_no_docker( @pytest.mark.skipif( sys.platform == "win32", reason="Windows paths aren't converted in Docker context" ) +@pytest.mark.parametrize( + "extras", + [ + # No extras + {}, + # Extras on a source directory + {"first": "[ex1]"}, + # Extras on a tarball + {"second": "[ex2]"}, + # Extras on a wheel + {"third": "[ex3]"}, + # Multiple extras on everything + {"first": "[exa,exb]", "second": "[exc,exd]", "third": "[exe,exf]"}, + ], +) def test_install_app_requirements_with_locals( create_command, first_app_config, @@ -259,31 +274,38 @@ def test_install_app_requirements_with_locals( second_package, # A pre-built sdist third_package, # A pre-built wheel other_package, # A stale local requirement + extras, ): - """If the app has local requirements, they are compiled into sdists for + """If the app has local requirements, they are compiled into wheels for installation.""" # Add local requirements - first_app_config.requires.extend([first_package, second_package, third_package]) + first_app_config.requires.extend( + [ + first_package + extras.get("first", ""), + second_package + extras.get("second", ""), + third_package + extras.get("third", ""), + ] + ) - # Mock the side effect of building an sdist - def build_sdist(*args, **kwargs): + # Mock the side effect of building a wheel + def build_wheel(*args, **kwargs): # Extract the folder name; assume that's the name of the package name = Path(args[0][-1]).name create_tgz_file( create_command.local_requirements_path(first_app_config) - / f"{name}-1.2.3.tar.gz", + / f"{name}-1.2.3-py3-none-any.whl", content=[ ("setup.py", "Python config"), ("local.py", "Python source"), ], ) - create_command.tools.subprocess.check_output.side_effect = build_sdist + create_command.tools.subprocess.check_output.side_effect = build_wheel # Install requirements create_command.install_app_requirements(first_app_config) - # An sdist was built for the local package + # A wheel was built for the local package create_command.tools.subprocess.check_output.assert_called_once_with( [ sys.executable, @@ -291,10 +313,10 @@ def build_sdist(*args, **kwargs): "utf8", "-m", "build", - "--sdist", + "--wheel", "--outdir", tmp_path / "base_path/build/first-app/tester/dummy/_requirements", - str(tmp_path / "local/first"), + tmp_path / "local/first", ], encoding="UTF-8", ) @@ -335,22 +357,22 @@ def build_sdist(*args, **kwargs): "--target=/app/path/to/app_packages", "foo==1.2.3", "bar>=4.5", - "/app/_requirements/first-1.2.3.tar.gz", - "/app/_requirements/second-2.3.4.tar.gz", - "/app/_requirements/third-3.4.5-py3-none-any.whl", + "/app/_requirements/first-1.2.3-py3-none-any.whl" + extras.get("first", ""), + "/app/_requirements/second-2.3.4.tar.gz" + extras.get("second", ""), + "/app/_requirements/third-3.4.5-py3-none-any.whl" + extras.get("third", ""), ], check=True, encoding="UTF-8", env={"DOCKER_CLI_HINTS": "false"}, ) - # The local requirements path exists, and contains the compiled sdist, the + # The local requirements path exists, and contains the compiled wheel, the # pre-existing sdist, and the pre-existing wheel; the old requirement has # been purged. local_requirements_path = create_command.local_requirements_path(first_app_config) assert local_requirements_path.exists() assert [f.name for f in sorted(local_requirements_path.iterdir())] == [ - "first-1.2.3.tar.gz", + "first-1.2.3-py3-none-any.whl", "second-2.3.4.tar.gz", "third-3.4.5-py3-none-any.whl", ] @@ -370,7 +392,7 @@ def test_install_app_requirements_with_bad_local( # Add a local requirement first_app_config.requires.append(first_package) - # Mock the building an sdist raising an error + # Mock the building an wheel raising an error create_command.tools.subprocess.check_output.side_effect = ( subprocess.CalledProcessError( cmd=["python", "-m", "build", "..."], returncode=1 @@ -380,11 +402,11 @@ def test_install_app_requirements_with_bad_local( # Install requirements with pytest.raises( BriefcaseCommandError, - match=r"Unable to build sdist for .*/local/first", + match=r"Unable to build wheel for .*/local/first", ): create_command.install_app_requirements(first_app_config) - # An attempt to build the sdist was made + # An attempt to build the wheel was made create_command.tools.subprocess.check_output.assert_called_once_with( [ sys.executable, @@ -392,10 +414,10 @@ def test_install_app_requirements_with_bad_local( "utf8", "-m", "build", - "--sdist", + "--wheel", "--outdir", tmp_path / "base_path/build/first-app/tester/dummy/_requirements", - str(tmp_path / "local/first"), + tmp_path / "local/first", ], encoding="UTF-8", ) @@ -429,7 +451,7 @@ def test_install_app_requirements_with_missing_local_build( ): create_command.install_app_requirements(first_app_config) - # No attempt to build the sdist was made + # No attempt to build the wheel was made create_command.tools.subprocess.check_output.assert_not_called() # pip was *not* invoked inside docker. @@ -463,11 +485,11 @@ def test_install_app_requirements_with_bad_local_file( # An attempt was made to copy the package create_command.tools.shutil.copy.assert_called_once_with( - str(tmp_path / "local/missing-2.3.4.tar.gz"), + tmp_path / "local/missing-2.3.4.tar.gz", tmp_path / "base_path/build/first-app/tester/dummy/_requirements", ) - # No attempt was made to build the sdist + # No attempt was made to build the wheel create_command.tools.subprocess.check_output.assert_not_called() # pip was *not* invoked inside docker.