Skip to content
Merged
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/2956.misc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Some internal file testing tools were refactored.
27 changes: 10 additions & 17 deletions src/briefcase/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import hashlib
import os
import platform
import re
import shutil
import subprocess
import sys
Expand All @@ -30,8 +29,6 @@

from .base import BaseCommand, full_options

relative_path_matcher = re.compile(r"^\.{1,2}[\\/]")


def cookiecutter_cache_path(template):
"""Determine the cookiecutter template cache directory given a template URL.
Expand Down Expand Up @@ -569,7 +566,12 @@ def _write_requirements_file(
f.write(f"{requirement}\n")

if requirement_installer_args_path:
pip_args = "\n".join(self._extra_pip_args(app))
pip_args = "\n".join(
self.tools.file.resolve_relative_args(
app.requirement_installer_args,
self.base_path,
)
)
requirement_installer_args_path.write_text(
f"{pip_args}\n", encoding="utf-8"
)
Expand All @@ -589,19 +591,10 @@ def _extra_pip_args(self, app: FinalizedAppConfig):
:param app: The app configuration
:returns: A list of additional arguments
"""
args: list[str] = []
for argument in app.requirement_installer_args:
to_append = argument
if relative_path_matcher.match(argument) and self.tools.file.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
return self.tools.file.resolve_relative_args(
app.requirement_installer_args,
self.base_path,
)

def _pip_install(
self,
Expand Down
110 changes: 68 additions & 42 deletions src/briefcase/integrations/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import itertools
import os
import re
import shutil
import ssl
import sys
Expand All @@ -22,47 +23,7 @@
)
from briefcase.integrations.base import Tool, ToolCache


def _has_url(requirement):
"""Determine if the requirement is defined as a URL.

Detects any of the URL schemes supported by pip
(https://pip.pypa.io/en/stable/topics/vcs-support/).

:param requirement: The requirement to check
:returns: True if the requirement is a URL supported by pip.
"""
return any(
f"{scheme}:" in requirement
for scheme in (
"http",
"https",
"file",
"ftp",
"git+file",
"git+https",
"git+ssh",
"git+http",
"git+git",
"git",
"hg+file",
"hg+http",
"hg+https",
"hg+ssh",
"hg+static-http",
"svn",
"svn+svn",
"svn+http",
"svn+https",
"svn+ssh",
"bzr+http",
"bzr+https",
"bzr+ssh",
"bzr+sftp",
"bzr+ftp",
"bzr+lp",
)
)
RELATIVE_PATH_RE = re.compile(r"^\.{1,2}[\\/]")


class File(Tool):
Expand Down Expand Up @@ -148,6 +109,47 @@ def ssl_context(self):

return self._ssl_context

def is_scm_url(self, path):
"""Determine if the requirement is defined as a URL.

Detects any of the URL schemes supported by pip
(https://pip.pypa.io/en/stable/topics/vcs-support/).

:param requirement: The requirement to check
:returns: True if the requirement is a URL supported by pip.
"""
return any(
f"{scheme}:" in path
for scheme in (
"http",
"https",
"file",
"ftp",
"git+file",
"git+https",
"git+ssh",
"git+http",
"git+git",
"git",
"hg+file",
"hg+http",
"hg+https",
"hg+ssh",
"hg+static-http",
"svn",
"svn+svn",
"svn+http",
"svn+https",
"svn+ssh",
"bzr+http",
"bzr+https",
"bzr+ssh",
"bzr+sftp",
"bzr+ftp",
"bzr+lp",
)
)

def is_local_path(self, reference: str | os.PathLike) -> bool:
"""Determine if the reference is a local file path.

Expand All @@ -159,7 +161,9 @@ def is_local_path(self, reference: str | os.PathLike) -> bool:
if os.altsep:
separators.append(os.altsep)

return any(sep in reference for sep in separators) and (not _has_url(reference))
return any(sep in reference for sep in separators) and (
not self.is_scm_url(reference)
)

def is_archive(self, filename: str | os.PathLike) -> bool:
"""Can a file be unpacked via `shutil.unpack_archive()`?
Expand All @@ -182,6 +186,28 @@ def is_archive(self, filename: str | os.PathLike) -> bool:
}
return not file_extensions.isdisjoint(self.supported_archive_extensions)

def resolve_relative_args(self, args: list[str], base_path: Path) -> list[str]:
"""Convert a list of arguments so that all relative file path references are
converted into resolved paths relative to the base path.

An argument is only converted if:
1. It "looks" like a relative path (i.e., it starts with a . or ..)
2. The path referenced actually exists

:param args: The initial list of arguments
:param base_path: The base path against which file references should be resolved
:returns: The resolved list of arguments.
"""
resolved_args: list[str] = []
for arg in args:
if RELATIVE_PATH_RE.match(arg) and self.tools.file.is_local_path(arg):
abs_path = (base_path / arg).resolve()
if abs_path.exists():
arg = abs_path

resolved_args.append(arg)
return resolved_args

@property
def supported_archive_extensions(self) -> set[str]:
return {
Expand Down
47 changes: 26 additions & 21 deletions tests/commands/create/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from briefcase.integrations.base import Tool
from briefcase.integrations.subprocess import Subprocess

from ...utils import create_file
from ...utils import create_file, create_toml_file


@pytest.fixture
Expand Down Expand Up @@ -274,70 +274,75 @@ def bundle_path(myapp, tmp_path):
@pytest.fixture
def app_packages_path_index(bundle_path):
(bundle_path / "path/to/app_packages").mkdir(parents=True, exist_ok=True)
with (bundle_path / "briefcase.toml").open("wb") as f:
index = {
create_toml_file(
bundle_path / "briefcase.toml",
{
"paths": {
"app_path": "path/to/app",
"app_packages_path": "path/to/app_packages",
"support_path": "path/to/support",
"support_revision": 37,
}
}
tomli_w.dump(index, f)
},
)


@pytest.fixture
def app_requirements_path_index(bundle_path):
with (bundle_path / "briefcase.toml").open("wb") as f:
index = {
create_toml_file(
bundle_path / "briefcase.toml",
{
"paths": {
"app_path": "path/to/app",
"app_requirements_path": "path/to/requirements.txt",
"support_path": "path/to/support",
"support_revision": 37,
}
}
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 = {
create_toml_file(
bundle_path / "briefcase.toml",
{
"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:
index = {
create_toml_file(
bundle_path / "briefcase.toml",
{
"paths": {
"app_path": "path/to/app",
"app_requirements_path": "path/to/requirements.txt",
"support_path": "path/to/support",
}
}
tomli_w.dump(index, f)
},
)


@pytest.fixture
def no_support_path_index(bundle_path):
with (bundle_path / "briefcase.toml").open("wb") as f:
index = {
create_toml_file(
bundle_path / "briefcase.toml",
{
"paths": {
"app_path": "path/to/app",
"app_requirements_path": "path/to/requirements.txt",
}
}
tomli_w.dump(index, f)
},
)


@pytest.fixture
Expand Down
2 changes: 1 addition & 1 deletion tests/commands/create/test_install_app_requirements.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ def test_app_packages_requirement_installer_args_path_transformed(
"--no-user",
f"--target={app_packages_path}",
"--extra-index-url",
os.path.abspath(create_command.base_path / "packages"),
(create_command.base_path / "packages").resolve(),
"package",
],
check=True,
Expand Down
34 changes: 34 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import inspect
import os
import platform
import shutil
import subprocess
import sys
import time
from unittest.mock import ANY, MagicMock

import pytest

from briefcase.config import DraftAppConfig
from briefcase.integrations.base import ToolCache
from briefcase.integrations.file import File
from briefcase.integrations.subprocess import Subprocess

from .utils import DummyConsole, create_file

Expand Down Expand Up @@ -132,3 +138,31 @@ def first_app(first_app_unbuilt, tmp_path):
)

return first_app_unbuilt


@pytest.fixture
def mock_tools(dummy_console, tmp_path) -> ToolCache:
mock_tools = ToolCache(
console=dummy_console,
base_path=tmp_path / "tools",
home_path=tmp_path / "home",
)

# Mock stdlib tools
mock_tools.os = MagicMock(spec_set=os)
mock_tools.platform = MagicMock(spec_set=platform)
mock_tools.shutil = MagicMock(spec_set=shutil)
mock_tools.sys = MagicMock(spec_set=sys)

# Mock an empty environment
mock_tools.os.environ = {}

# Create base directories
mock_tools.base_path.mkdir(parents=True)
mock_tools.home_path.mkdir(parents=True)

# Make File and Subprocess always available
File.verify(tools=mock_tools)
Subprocess.verify(tools=mock_tools)

return mock_tools
Loading