Skip to content
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
1 change: 1 addition & 0 deletions changes/2700.bugfix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Linux projects can now install extras on requirements that are specified as a reference to a local source directory.
73 changes: 41 additions & 32 deletions src/briefcase/platforms/linux/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
Expand All @@ -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
Expand Down
66 changes: 44 additions & 22 deletions tests/platforms/linux/test_LocalRequirementsMixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -259,42 +274,49 @@ 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,
"-X",
"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",
)
Expand Down Expand Up @@ -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",
]
Expand All @@ -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
Expand All @@ -380,22 +402,22 @@ 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,
"-X",
"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",
)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down