Skip to content

Commit a90852d

Browse files
authored
Merge pull request #1492 from freakboy3742/macos-only-binary
Only install binary packages on the second install pass.
2 parents 042a9f9 + d8e7fc8 commit a90852d

File tree

13 files changed

+269
-106
lines changed

13 files changed

+269
-106
lines changed

changes/1482.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
macOS apps can now be configured to produce single platform binaries, or binaries that will work on both x86_64 and ARM64.

changes/1492.misc.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The second binary wheel installation pass (for the other architecture) on macOS now requires binary wheels, disabling source compilation.

docs/reference/platforms/macOS/app.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ Do not submit the application for notarization. By default, apps will be
5353
submitted for notarization unless they have been signed with an ad-hoc
5454
signing identity.
5555

56+
Application configuration
57+
=========================
58+
59+
The following options can be added to the ``tool.briefcase.app.<appname>.macOS.app``
60+
section of your ``pyproject.toml`` file.
61+
62+
``universal_build``
63+
~~~~~~~~~~~~~~~~~~~
64+
65+
A Boolean, indicating whether Briefcase should build a universal app (i.e, an app that
66+
can target both x86_64 and ARM64). Defaults to ``true``; if ``false``, the binary will
67+
only be executable on the host platform on which it was built - i.e., if you build on
68+
an x86_64 machine, you will produce an x86_65 binary; if you build on an ARM64 machine,
69+
you will produce an ARM64 binary.
70+
5671
Platform quirks
5772
===============
5873

docs/reference/platforms/macOS/xcode.rst

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,21 @@ Do not submit the application for notarization. By default, apps will be
5959
submitted for notarization unless they have been signed with an ad-hoc
6060
signing identity.
6161

62+
Application configuration
63+
=========================
64+
65+
The following options can be added to the ``tool.briefcase.app.<appname>.macOS.Xcode``
66+
section of your ``pyproject.toml`` file.
67+
68+
``universal_build``
69+
~~~~~~~~~~~~~~~~~~~
70+
71+
A Boolean, indicating whether Briefcase should build a universal app (i.e, an app that
72+
can target both x86_64 and ARM64). Defaults to ``true``; if ``false``, the binary will
73+
only be executable on the host platform on which it was built - i.e., if you build on
74+
an x86_64 machine, you will produce an x86_65 binary; if you build on an ARM64 machine,
75+
you will produce an ARM64 binary.
76+
6277
Platform quirks
6378
===============
6479

src/briefcase/commands/create.py

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,8 @@ def generate_app_template(self, app: AppConfig):
208208
# Properties of the generating environment
209209
# The full Python version string, including minor and dev/a/b/c suffixes (e.g., 3.11.0rc2)
210210
"python_version": platform.python_version(),
211+
# The host architecture
212+
"host_arch": self.tools.host_arch,
211213
# The Briefcase version
212214
"briefcase_version": briefcase.__version__,
213215
# Transformations of explicit properties into useful forms
@@ -445,19 +447,23 @@ def _extra_pip_args(self, app: AppConfig):
445447
def _pip_install(
446448
self,
447449
app: AppConfig,
448-
requires: list[str],
449450
app_packages_path: Path,
450-
include_deps: bool = True,
451-
**pip_kwargs: dict[str, str],
451+
pip_args: list[str],
452+
install_hint: str = "",
453+
**pip_kwargs,
452454
):
453455
"""Invoke pip to install a set of requirements.
454456
455457
:param app: The app configuration
456-
:param requires: The list of requirements to install
457458
:param app_packages_path: The full path of the app_packages folder into which
458459
requirements should be installed.
459-
:param progress_message: The waitbar progress message to display to the user.
460-
:param pip_kwargs: Any additional keyword arguments to pass to the subprocess
460+
:param pip_args: The list of arguments (including the list of requirements to
461+
install) to pass to pip. This is in addition to the default arguments that
462+
disable pip version checks, forces upgrades, and installs into the nominated
463+
``app_packages`` path.
464+
:param install_hint: Additional hint information to provide in the exception
465+
message if the pip install call fails.
466+
:param pip_kwargs: Any additional keyword arguments to pass to ``subprocess.run``
461467
when invoking pip.
462468
"""
463469
try:
@@ -476,21 +482,14 @@ def _pip_install(
476482
"--no-user",
477483
f"--target={app_packages_path}",
478484
]
479-
+ (
480-
[
481-
"--no-deps",
482-
]
483-
if not include_deps
484-
else []
485-
)
486485
+ self._extra_pip_args(app)
487-
+ requires,
486+
+ pip_args,
488487
check=True,
489488
encoding="UTF-8",
490489
**pip_kwargs,
491490
)
492491
except subprocess.CalledProcessError as e:
493-
raise RequirementsInstallError() from e
492+
raise RequirementsInstallError(install_hint=install_hint) from e
494493

495494
def _install_app_requirements(
496495
self,
@@ -520,8 +519,8 @@ def _install_app_requirements(
520519
with self.input.wait_bar(progress_message):
521520
self._pip_install(
522521
app,
523-
requires=self._pip_requires(app, requires),
524522
app_packages_path=app_packages_path,
523+
pip_args=self._pip_requires(app, requires),
525524
**(pip_kwargs if pip_kwargs else {}),
526525
)
527526
else:

src/briefcase/exceptions.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,12 +184,12 @@ def __init__(self, python_version_tag, platform, host_arch, is_32bit):
184184

185185

186186
class RequirementsInstallError(BriefcaseCommandError):
187-
def __init__(self):
187+
def __init__(self, install_hint=""):
188188
super().__init__(
189-
"""\
189+
f"""\
190190
Unable to install requirements. This may be because one of your
191191
requirements is invalid, or because pip was unable to connect
192-
to the PyPI server.
192+
to the PyPI server.{install_hint}
193193
"""
194194
)
195195

src/briefcase/platforms/macOS/__init__.py

Lines changed: 96 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -46,77 +46,106 @@ def _install_app_requirements(
4646
requires: list[str],
4747
app_packages_path: Path,
4848
):
49-
# Perform the initial install targeting the current platform
50-
host_app_packages_path = (
51-
self.bundle_path(app) / f"app_packages.{self.tools.host_arch}"
52-
)
53-
super()._install_app_requirements(
54-
app,
55-
requires=requires,
56-
app_packages_path=host_app_packages_path,
57-
)
58-
59-
# Find all the packages with binary components.
60-
# We can ignore any -universal2 packages; they're already fat.
61-
binary_packages = self.find_binary_packages(
62-
host_app_packages_path,
63-
universal_suffix="_universal2",
64-
)
49+
if getattr(app, "universal_build", True):
50+
# Perform the initial install targeting the current platform
51+
host_app_packages_path = (
52+
self.bundle_path(app) / f"app_packages.{self.tools.host_arch}"
53+
)
54+
super()._install_app_requirements(
55+
app,
56+
requires=requires,
57+
app_packages_path=host_app_packages_path,
58+
)
6559

66-
# Now install dependencies for the architecture that isn't the host architecture.
67-
other_arch = {
68-
"arm64": "x86_64",
69-
"x86_64": "arm64",
70-
}[self.tools.host_arch]
60+
# Find all the packages with binary components.
61+
# We can ignore any -universal2 packages; they're already fat.
62+
binary_packages = self.find_binary_packages(
63+
host_app_packages_path,
64+
universal_suffix="_universal2",
65+
)
7166

72-
# Create a temporary folder targeting the other platform
73-
other_app_packages_path = self.bundle_path(app) / f"app_packages.{other_arch}"
74-
if other_app_packages_path.is_dir():
75-
self.tools.shutil.rmtree(other_app_packages_path)
76-
self.tools.os.mkdir(other_app_packages_path)
67+
# Now install dependencies for the architecture that isn't the host architecture.
68+
other_arch = {
69+
"arm64": "x86_64",
70+
"x86_64": "arm64",
71+
}[self.tools.host_arch]
7772

78-
if binary_packages:
79-
with self.input.wait_bar(
80-
f"Installing binary app requirements for {other_arch}..."
81-
):
82-
self._pip_install(
83-
app,
84-
requires=[
85-
f"{package}=={version}" for package, version in binary_packages
86-
],
87-
app_packages_path=other_app_packages_path,
88-
include_deps=False,
89-
env={
90-
"PYTHONPATH": str(
91-
self.support_path(app)
92-
/ "platform-site"
93-
/ f"macosx.{other_arch}"
94-
)
95-
},
96-
)
73+
# Create a temporary folder targeting the other platform
74+
other_app_packages_path = (
75+
self.bundle_path(app) / f"app_packages.{other_arch}"
76+
)
77+
if other_app_packages_path.is_dir():
78+
self.tools.shutil.rmtree(other_app_packages_path)
79+
self.tools.os.mkdir(other_app_packages_path)
80+
81+
if binary_packages:
82+
with self.input.wait_bar(
83+
f"Installing binary app requirements for {other_arch}..."
84+
):
85+
self._pip_install(
86+
app,
87+
app_packages_path=other_app_packages_path,
88+
pip_args=[
89+
"--no-deps",
90+
"--only-binary",
91+
":all:",
92+
]
93+
+ [
94+
f"{package}=={version}"
95+
for package, version in binary_packages
96+
],
97+
install_hint=f"""
98+
99+
If an {other_arch} wheel has not been published for one or more of your requirements,
100+
you must compile those wheels yourself, or build a non-universal app by setting:
101+
102+
universal_build = False
103+
104+
in the macOS configuration section of your pyproject.toml.
105+
""",
106+
env={
107+
"PYTHONPATH": str(
108+
self.support_path(app)
109+
/ "platform-site"
110+
/ f"macosx.{other_arch}"
111+
)
112+
},
113+
)
114+
else:
115+
self.logger.info("All packages are pure Python, or universal.")
116+
117+
# If given the option of a single architecture binary or a universal2 binary,
118+
# pip will install the single platform binary. However, a common situation on
119+
# macOS is for there to be an x86_64 binary and a universal2 binary. This means
120+
# you only get a universal2 binary in the "other" install pass. This then causes
121+
# problems with merging, because the "other" binary contains a copy of the
122+
# architecture that the "host" platform provides.
123+
#
124+
# To avoid this - ensure that the libraries in the app packages for the "other"
125+
# arch are all thin.
126+
#
127+
# This doesn't matter if it happens the other way around - if the "host" arch
128+
# installs a universal binary, then the "other" arch won't be asked to install
129+
# a binary at all.
130+
self.thin_app_packages(other_app_packages_path, arch=other_arch)
131+
132+
# Merge the binaries
133+
self.merge_app_packages(
134+
target_app_packages=app_packages_path,
135+
sources=[host_app_packages_path, other_app_packages_path],
136+
)
97137
else:
98-
self.logger.info("All packages are pure Python, or universal.")
99-
100-
# If given the option of a single architecture binary or a universal2 binary,
101-
# pip will install the single platform binary. However, a common situation on
102-
# macOS is for there to be an x86_64 binary and a universal2 binary. This means
103-
# you only get a universal2 binary in the "other" install pass. This then causes
104-
# problems with merging, because the "other" binary contains a copy of the
105-
# architecture that the "host" platform provides.
106-
#
107-
# To avoid this - ensure that the libraries in the app packages for the "other"
108-
# arch are all thin.
109-
#
110-
# This doesn't matter if it happens the other way around - if the "host" arch
111-
# installs a universal binary, then the "other" arch won't be asked to install
112-
# a binary at all.
113-
self.thin_app_packages(other_app_packages_path, arch=other_arch)
114-
115-
# Merge the binaries
116-
self.merge_app_packages(
117-
target_app_packages=app_packages_path,
118-
sources=[host_app_packages_path, other_app_packages_path],
119-
)
138+
# If we're not building a universal binary, we can do a single install pass
139+
# directly into the app_packages folder.
140+
super()._install_app_requirements(
141+
app,
142+
requires=requires,
143+
app_packages_path=app_packages_path,
144+
)
145+
146+
# Since we're only targeting 1 architecture, we can strip any universal
147+
# libraries down to just the host architecture.
148+
self.thin_app_packages(app_packages_path, arch=self.tools.host_arch)
120149

121150

122151
class macOSRunMixin:

src/briefcase/platforms/macOS/app.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,15 @@ def install_app_support_package(self, app: AppConfig):
5959
runtime_support_path / "python-stdlib",
6060
)
6161

62+
if not getattr(app, "universal_build", True):
63+
with self.input.wait_bar("Ensuring stub binary is thin..."):
64+
# The stub binary is universal by default. If we're building a non-universal app,
65+
# we can strip the binary to remove the unused slice.
66+
self.ensure_thin_binary(
67+
self.binary_path(app) / "Contents" / "MacOS" / app.formal_name,
68+
arch=self.tools.host_arch,
69+
)
70+
6271

6372
class macOSAppUpdateCommand(macOSAppCreateCommand, UpdateCommand):
6473
description = "Update an existing macOS app."

src/briefcase/platforms/macOS/utils.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,8 +67,8 @@ def find_binary_packages(
6767

6868
return binary_packages
6969

70-
def ensure_thin_dylib(self, path: Path, arch: str):
71-
"""Ensure that a library is thin, targeting a given architecture.
70+
def ensure_thin_binary(self, path: Path, arch: str):
71+
"""Ensure that a binary is thin, targeting a given architecture.
7272
7373
If the library is already thin, it is left as-is.
7474
@@ -107,7 +107,7 @@ def ensure_thin_dylib(self, path: Path, arch: str):
107107
)
108108
except subprocess.CalledProcessError as e:
109109
raise BriefcaseCommandError(
110-
f"Unable to create thin library from {path}"
110+
f"Unable to create thin binary from {path}"
111111
) from e
112112
else:
113113
# Having extracted the single architecture into a temporary
@@ -182,7 +182,7 @@ def thin_app_packages(
182182
futures = []
183183
for path in dylibs:
184184
future = executor.submit(
185-
self.ensure_thin_dylib,
185+
self.ensure_thin_binary,
186186
path=path,
187187
arch=arch,
188188
)

tests/commands/create/test_generate_app_template.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ def full_context():
4141
"document_types": {},
4242
# Properties of the generating environment
4343
"python_version": platform.python_version(),
44+
"host_arch": "gothic",
4445
"briefcase_version": briefcase.__version__,
4546
# Fields generated from other properties
4647
"module_name": "my_app",

0 commit comments

Comments
 (0)