Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow for local file references in requirements when building AppImages #993

Merged
merged 8 commits into from
Dec 7, 2022
1 change: 1 addition & 0 deletions changes/991.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``briefcase open linux appimage`` now starts a shell session in the Docker context, rather than opening the project folder.
1 change: 1 addition & 0 deletions changes/992.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Local file references in requirements no longer break AppImage builds.
77 changes: 54 additions & 23 deletions src/briefcase/commands/create.py
Original file line number Diff line number Diff line change
Expand Up @@ -427,10 +427,6 @@ def _write_requirements_file(
:param requirements_path: The full path to a requirements.txt file that
will be written.
"""
# Windows allows both / and \ as a path separator in requirements.
separators = [os.sep]
if os.altsep:
separators.append(os.altsep)

with self.input.wait_bar("Writing requirements file..."):
with requirements_path.open("w", encoding="utf-8") as f:
Expand All @@ -439,14 +435,21 @@ def _write_requirements_file(
# 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 any(sep in requirement for sep in separators) and (
not _has_url(requirement)
):
if _is_local_requirement(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")

def _pip_requires(self, requires: List[str]):
"""Convert the list of requirements to be passed to pip into its final
form.

:param requires: The user-specified list of app requirements
:returns: The final list of requirement arguments to pass to pip
"""
return requires

def _extra_pip_args(self, app: BaseConfig):
"""Any additional arguments that must be passed to pip when installing
packages.
Expand All @@ -456,6 +459,26 @@ def _extra_pip_args(self, app: BaseConfig):
"""
return []

def _pip_kwargs(self, app: BaseConfig):
"""Generate the kwargs to pass when invoking pip.

:param app: The app configuration
:returns: The kwargs to pass to the pip call
"""
# If there is a support package provided, add the cross-platform
# folder of the support package to the PYTHONPATH. This allows
# a support package to specify a sitecustomize.py that will make
# pip behave as if it was being run on the target platform.
pip_kwargs = {}
try:
pip_kwargs["env"] = {
"PYTHONPATH": str(self.support_path(app) / "platform-site"),
}
except KeyError:
pass

return pip_kwargs

def _install_app_requirements(
self,
app: BaseConfig,
Expand All @@ -477,18 +500,6 @@ def _install_app_requirements(
# Install requirements
if requires:
with self.input.wait_bar("Installing app requirements..."):
# If there is a support package provided, add the cross-platform
# folder of the support package to the PYTHONPATH. This allows
# a support package to specify a sitecustomize.py that will make
# pip behave as if it was being run on the target platform.
pip_kwargs = {}
try:
pip_kwargs["env"] = {
"PYTHONPATH": str(self.support_path(app) / "platform-site"),
}
except KeyError:
pass

try:
self.tools[app].app_context.run(
[
Expand All @@ -502,9 +513,9 @@ def _install_app_requirements(
f"--target={app_packages_path}",
]
+ self._extra_pip_args(app)
+ requires,
+ self._pip_requires(requires),
check=True,
**pip_kwargs,
**self._pip_kwargs(app),
)
except subprocess.CalledProcessError as e:
raise RequirementsInstallError() from e
Expand Down Expand Up @@ -828,9 +839,15 @@ def __call__(self, app: Optional[BaseConfig] = None, **options):
return state


# Detects any of the URL schemes supported by pip
# (https://pip.pypa.io/en/stable/topics/vcs-support/).
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 (
Expand All @@ -841,3 +858,17 @@ def _has_url(requirement):
+ ["bzr+http", "bzr+https", "bzr+ssh", "bzr+sftp", "bzr+ftp", "bzr+lp"]
)
)


def _is_local_requirement(requirement):
"""Determine if the requirement is a local file path.

:param requirement: The requirement to check
:returns: True if the requirement is a local file path
"""
# Windows allows both / and \ as a path separator in requirements.
separators = [os.sep]
if os.altsep:
separators.append(os.altsep)

return any(sep in requirement for sep in separators) and (not _has_url(requirement))
10 changes: 5 additions & 5 deletions src/briefcase/commands/open.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@
class OpenCommand(BaseCommand):
command = "open"

def open_project(self, project_path):
def _open_app(self, app: BaseConfig):
if self.tools.host_os == "Windows":
self.tools.os.startfile(project_path)
self.tools.os.startfile(self.project_path(app))
elif self.tools.host_os == "Darwin":
self.tools.subprocess.Popen(["open", project_path])
self.tools.subprocess.Popen(["open", self.project_path(app)])
else:
self.tools.subprocess.Popen(["xdg-open", project_path])
self.tools.subprocess.Popen(["xdg-open", self.project_path(app)])

def open_app(self, app: BaseConfig, **options):
"""Open the project for an app.
Expand All @@ -33,7 +33,7 @@ def open_app(self, app: BaseConfig, **options):
f"Opening {self.project_path(app).relative_to(self.base_path)}...",
prefix=app.app_name,
)
self.open_project(project_path)
self._open_app(app)

return state

Expand Down
49 changes: 35 additions & 14 deletions src/briefcase/integrations/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,7 @@ def _dockerize_path(self, arg: str):

return arg

def _dockerize_args(self, args, env=None):
def _dockerize_args(self, args, interactive=False, mounts=None, env=None):
"""Convert arguments and environment into a Docker-compatible form.
Convert an argument and environment specification into a form that can
be used as arguments to invoke Docker. This involves:
Expand All @@ -330,17 +330,30 @@ def _dockerize_args(self, args, env=None):
:returns: A list of arguments that can be used to invoke the command
inside a docker container.
"""
docker_args = ["docker", "run", "--rm"]

# Add "-it" if in interactive mode
if interactive:
docker_args.append("-it")

# Add default volume mounts for the app folder, plus the Briefcase data
# path.
#
# The :z suffix on volume mounts allows SELinux to modify the host
# mount; it is ignored on non-SELinux platforms.
docker_args = [
"docker",
"run",
"--volume",
f"{self.host_platform_path}:/app:z",
"--volume",
f"{self.host_data_path}:{self.docker_data_path}:z",
"--rm",
]
docker_args.extend(
[
"--volume",
f"{self.host_platform_path}:/app:z",
"--volume",
f"{self.host_data_path}:{self.docker_data_path}:z",
]
)

# Add any extra volume mounts
if mounts:
for source, target in mounts:
docker_args.extend(["--volume", f"{source}:{target}:z"])

# If any environment variables have been defined, pass them in
# as --env arguments to Docker.
Expand All @@ -356,24 +369,32 @@ def _dockerize_args(self, args, env=None):

return docker_args

def run(self, args, env=None, **kwargs):
def run(self, args, env=None, interactive=False, mounts=None, **kwargs):
"""Run a process inside a Docker container."""
# Any exceptions from running the process are *not* caught.
# This ensures that "docker.run()" behaves as closely to
# "subprocess.run()" as possible.
self.tools.logger.info("Entering Docker context...", prefix=self.app.app_name)
if interactive:
kwargs["stream_output"] = False

self.tools.subprocess.run(
self._dockerize_args(args, env=env),
self._dockerize_args(
args,
interactive=interactive,
mounts=mounts,
env=env,
),
**kwargs,
)
self.tools.logger.info("Leaving Docker context", prefix=self.app.app_name)

def check_output(self, args, env=None, **kwargs):
def check_output(self, args, env=None, mounts=None, **kwargs):
"""Run a process inside a Docker container, capturing output."""
# Any exceptions from running the process are *not* caught.
# This ensures that "docker.check_output()" behaves as closely to
# "subprocess.check_output()" as possible.
return self.tools.subprocess.check_output(
self._dockerize_args(args, env=env),
self._dockerize_args(args, mounts=mounts, env=env),
**kwargs,
)
Loading