Skip to content

Commit dc4584f

Browse files
committed
Allow running Linux System apps for foreign targets
1 parent e650795 commit dc4584f

26 files changed

+2599
-968
lines changed

changes/1603.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The ``briefcase run`` command now supports the ``--target`` option to run Linux apps from within Docker for other distributions.

src/briefcase/bootstraps/toga.py

+8
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,9 @@ def pyproject_table_linux_system_debian(self):
7171
"libcairo2-dev",
7272
# Needed to compile PyGObject wheel
7373
"libgirepository1.0-dev",
74+
# Needed to run the app
75+
"gir1.2-gtk-3.0",
76+
# "gir1.2-webkit2-4.0",
7477
]
7578
7679
system_runtime_requires = [
@@ -91,6 +94,8 @@ def pyproject_table_linux_system_rhel(self):
9194
"cairo-gobject-devel",
9295
# Needed to compile PyGObject wheel
9396
"gobject-introspection-devel",
97+
# Needed to run the app
98+
"gtk3",
9499
]
95100
96101
system_runtime_requires = [
@@ -112,6 +117,9 @@ def pyproject_table_linux_system_suse(self):
112117
"cairo-devel",
113118
# Needed to compile PyGObject wheel
114119
"gobject-introspection-devel",
120+
# Needed to run the app
121+
"gtk3", "typelib-1_0-Gtk-3_0",
122+
# "libwebkit2gtk3", "typelib-1_0-WebKit2-4_1",
115123
]
116124
117125
system_runtime_requires = [

src/briefcase/integrations/docker.py

+490-165
Large diffs are not rendered by default.

src/briefcase/integrations/subprocess.py

+17-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import contextlib
34
import json
45
import operator
56
import os
@@ -109,18 +110,17 @@ def ensure_console_is_safe(sub_method):
109110
"""
110111

111112
@wraps(sub_method)
112-
def inner(sub: Subprocess, sub_args: SubprocessArgsT, *args, **kwargs):
113+
def inner(sub: Subprocess, args: SubprocessArgsT, *wrapped_args, **wrapped_kwargs):
113114
"""Evaluate whether conditions are met to remove any dynamic elements in the
114115
console before returning control to Subprocess.
115116
116117
:param sub: Bound Subprocess object
117-
:param sub_args: command line to run in subprocess
118-
:param args: list of implicit strings that will be run as subprocess command
118+
:param args: command line to run in subprocess
119119
:return: the return value for the Subprocess method
120120
"""
121121
# Just run the command if no dynamic elements are active
122122
if not sub.tools.input.is_console_controlled:
123-
return sub_method(sub, sub_args, *args, **kwargs)
123+
return sub_method(sub, args, *wrapped_args, **wrapped_kwargs)
124124

125125
remove_dynamic_elements = False
126126

@@ -129,18 +129,18 @@ def inner(sub: Subprocess, sub_args: SubprocessArgsT, *args, **kwargs):
129129
# it may prompt the user to abort the script and dynamic elements
130130
# such as the Wait Bar can hide this message from the user.
131131
if sub.tools.host_os == "Windows":
132-
executable = str(sub_args[0]).strip() if sub_args else ""
132+
executable = str(args[0]).strip() if args else ""
133133
remove_dynamic_elements |= executable.lower().endswith(".bat")
134134

135135
# Release control for commands that cannot be streamed.
136-
remove_dynamic_elements |= kwargs.get("stream_output") is False
136+
remove_dynamic_elements |= wrapped_kwargs.get("stream_output") is False
137137

138138
# Run subprocess command with or without console control
139139
if remove_dynamic_elements:
140140
with sub.tools.input.release_console_control():
141-
return sub_method(sub, sub_args, *args, **kwargs)
141+
return sub_method(sub, args, *wrapped_args, **wrapped_kwargs)
142142
else:
143-
return sub_method(sub, sub_args, *args, **kwargs)
143+
return sub_method(sub, args, *wrapped_args, **wrapped_kwargs)
144144

145145
return inner
146146

@@ -177,6 +177,15 @@ def prepare(self):
177177
# This is a no-op; the native subprocess environment is ready-to-use.
178178
pass
179179

180+
@contextlib.contextmanager
181+
def run_app_context(self, subprocess_kwargs: dict[str, ...]) -> dict[str, ...]:
182+
"""A manager to wrap subprocess calls to run a Briefcase project app.
183+
184+
:param subprocess_kwargs: initialized keyword arguments for subprocess calls
185+
"""
186+
# This is a no-op; the native subprocess environment is ready-to-use.
187+
yield subprocess_kwargs
188+
180189
def full_env(self, overrides: dict[str, str]) -> dict[str, str]:
181190
"""Generate the full environment in which the command will run.
182191

src/briefcase/platforms/linux/system.py

+35-49
Original file line numberDiff line numberDiff line change
@@ -43,18 +43,26 @@ class LinuxSystemPassiveMixin(LinuxMixin):
4343

4444
@property
4545
def use_docker(self):
46-
# The passive mixing doesn't expose the `--target` option, as it can't use
47-
# Docker. However, we need the use_docker property to exist so that the
48-
# app config can be finalized in the general case.
49-
return False
46+
# The system backend doesn't have a literal "--use-docker" option, but
47+
# `use_docker` is a useful flag for shared logic purposes, so evaluate
48+
# what "use docker" means in terms of target_image.
49+
return bool(self.target_image)
50+
51+
def add_options(self, parser):
52+
super().add_options(parser)
53+
parser.add_argument(
54+
"--target",
55+
dest="target",
56+
help="Docker base image tag for the distribution to target for the build (e.g., `ubuntu:jammy`)",
57+
required=False,
58+
)
5059

5160
def parse_options(self, extra):
52-
# The passive mixin doesn't expose the `--target` option, but if run infers
53-
# build, we need target image to be defined.
54-
options = super().parse_options(extra)
55-
self.target_image = None
61+
"""Extract the target_image option."""
62+
options, overrides = super().parse_options(extra)
63+
self.target_image = options.pop("target")
5664

57-
return options
65+
return options, overrides
5866

5967
def build_path(self, app):
6068
# Override the default build path to use the vendor name,
@@ -196,13 +204,6 @@ class LinuxSystemMostlyPassiveMixin(LinuxSystemPassiveMixin):
196204
# The Mostly Passive mixin verifies that Docker exists and can be run, but
197205
# doesn't require that we're actually in a Linux environment.
198206

199-
@property
200-
def use_docker(self):
201-
# The system backend doesn't have a literal "--use-docker" option, but
202-
# `use_docker` is a useful flag for shared logic purposes, so evaluate
203-
# what "use docker" means in terms of target_image.
204-
return bool(self.target_image)
205-
206207
def app_python_version_tag(self, app: AppConfig):
207208
if self.use_docker:
208209
# If we're running in Docker, we can't know the Python3 version
@@ -372,22 +373,6 @@ def verify_tools(self):
372373
if self.use_docker:
373374
Docker.verify(tools=self.tools, image_tag=self.target_image)
374375

375-
def add_options(self, parser):
376-
super().add_options(parser)
377-
parser.add_argument(
378-
"--target",
379-
dest="target",
380-
help="Docker base image tag for the distribution to target for the build (e.g., `ubuntu:jammy`)",
381-
required=False,
382-
)
383-
384-
def parse_options(self, extra):
385-
"""Extract the target_image option."""
386-
options, overrides = super().parse_options(extra)
387-
self.target_image = options.pop("target")
388-
389-
return options, overrides
390-
391376
def clone_options(self, command):
392377
"""Clone the target_image option."""
393378
super().clone_options(command)
@@ -792,7 +777,7 @@ def build_app(self, app: AppConfig, **kwargs):
792777
self.tools.subprocess.check_output(["strip", self.binary_path(app)])
793778

794779

795-
class LinuxSystemRunCommand(LinuxSystemPassiveMixin, RunCommand):
780+
class LinuxSystemRunCommand(LinuxSystemMixin, RunCommand):
796781
description = "Run a Linux system project."
797782
supported_host_os = {"Linux"}
798783
supported_host_os_reason = "Linux system projects can only be executed on Linux."
@@ -809,23 +794,24 @@ def run_app(
809794
# Set up the log stream
810795
kwargs = self._prepare_app_env(app=app, test_mode=test_mode)
811796

812-
# Start the app in a way that lets us stream the logs
813-
app_popen = self.tools.subprocess.Popen(
814-
[os.fsdecode(self.binary_path(app))] + passthrough,
815-
cwd=self.tools.home_path,
816-
**kwargs,
817-
stdout=subprocess.PIPE,
818-
stderr=subprocess.STDOUT,
819-
bufsize=1,
820-
)
797+
with self.tools[app].app_context.run_app_context(kwargs) as kwargs:
798+
# Start the app in a way that lets us stream the logs
799+
app_popen = self.tools[app].app_context.Popen(
800+
[os.fsdecode(self.binary_path(app))] + passthrough,
801+
cwd=self.tools.home_path,
802+
stdout=subprocess.PIPE,
803+
stderr=subprocess.STDOUT,
804+
bufsize=1,
805+
**kwargs,
806+
)
821807

822-
# Start streaming logs for the app.
823-
self._stream_app_logs(
824-
app,
825-
popen=app_popen,
826-
test_mode=test_mode,
827-
clean_output=False,
828-
)
808+
# Start streaming logs for the app.
809+
self._stream_app_logs(
810+
app,
811+
popen=app_popen,
812+
test_mode=test_mode,
813+
clean_output=False,
814+
)
829815

830816

831817
def debian_multiline_description(description):

tests/commands/new/test_build_context.py

+16
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,9 @@ def main():
119119
"libcairo2-dev",
120120
# Needed to compile PyGObject wheel
121121
"libgirepository1.0-dev",
122+
# Needed to run the app
123+
"gir1.2-gtk-3.0",
124+
# "gir1.2-webkit2-4.0",
122125
]
123126
124127
system_runtime_requires = [
@@ -137,6 +140,8 @@ def main():
137140
"cairo-gobject-devel",
138141
# Needed to compile PyGObject wheel
139142
"gobject-introspection-devel",
143+
# Needed to run the app
144+
"gtk3",
140145
]
141146
142147
system_runtime_requires = [
@@ -156,6 +161,9 @@ def main():
156161
"cairo-devel",
157162
# Needed to compile PyGObject wheel
158163
"gobject-introspection-devel",
164+
# Needed to run the app
165+
"gtk3", "typelib-1_0-Gtk-3_0",
166+
# "libwebkit2gtk3", "typelib-1_0-WebKit2-4_1",
159167
]
160168
161169
system_runtime_requires = [
@@ -1125,6 +1133,9 @@ def main():
11251133
"libcairo2-dev",
11261134
# Needed to compile PyGObject wheel
11271135
"libgirepository1.0-dev",
1136+
# Needed to run the app
1137+
"gir1.2-gtk-3.0",
1138+
# "gir1.2-webkit2-4.0",
11281139
]
11291140
11301141
system_runtime_requires = [
@@ -1143,6 +1154,8 @@ def main():
11431154
"cairo-gobject-devel",
11441155
# Needed to compile PyGObject wheel
11451156
"gobject-introspection-devel",
1157+
# Needed to run the app
1158+
"gtk3",
11461159
]
11471160
11481161
system_runtime_requires = [
@@ -1162,6 +1175,9 @@ def main():
11621175
"cairo-devel",
11631176
# Needed to compile PyGObject wheel
11641177
"gobject-introspection-devel",
1178+
# Needed to run the app
1179+
"gtk3", "typelib-1_0-Gtk-3_0",
1180+
# "libwebkit2gtk3", "typelib-1_0-WebKit2-4_1",
11651181
]
11661182
11671183
system_runtime_requires = [

0 commit comments

Comments
 (0)