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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1410,7 +1410,7 @@ jobs:
cp dist/Wheel/*.whl dist/pypi/

- name: Publish release to PyPI (Trusted Publishing)
if: needs.analyze.outputs.release_type == 'tagged'
if: needs.analyze.outputs.release_type == 'tagged' && github.event.repository.fork == false
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/pypi
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci/workflow_template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ jobs:
cp dist/Wheel/*.whl dist/pypi/

- name: Publish release to PyPI (Trusted Publishing)
if: needs.analyze.outputs.release_type == 'tagged'
if: needs.analyze.outputs.release_type == 'tagged' && github.event.repository.fork == false
uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist/pypi
Expand Down
1 change: 1 addition & 0 deletions news.d/bugfix/1820.osx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed startup hang when launching Plover on Intel Macs
58 changes: 58 additions & 0 deletions osx/check_universal.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/bin/bash
# Verify all binaries in a Python.framework are universal (arm64 + x86_64).
# Usage: check_universal.sh <framework_path> <python_base_version>
# Example: check_universal.sh .../Python.framework 3.13

set -e

if [ $# -ne 2 ]; then
echo "Usage: $0 <framework_path> <python_base_version>"
exit 1
fi

framework_path="$1"
python_version="$2"

if [[ $python_version != *"3."* ]]; then
echo "Invalid Python version: $python_version"
exit 1
fi

STATUS=0

# Ensure all .so and .dylib files are universal.
LIB_COUNT=$(find "$framework_path" -name "*.so" -or -name "*.dylib" | wc -l)
UNIVERSAL_COUNT=$(find "$framework_path" -name "*.so" -or -name "*.dylib" | xargs file | grep "2 architectures" | wc -l)
if [ "$LIB_COUNT" != "$UNIVERSAL_COUNT" ]; then
echo "$LIB_COUNT libraries (*.so and *.dylib) found in the framework; only $UNIVERSAL_COUNT are universal!"
echo "The following libraries are not universal:"
find "$framework_path" -name "*.so" -or -name "*.dylib" | xargs file | grep -v "2 architectures" | grep -v "(for architecture"
STATUS=1
fi

# Check key binaries in the framework.
KEY_BINARIES="$framework_path/Versions/Current/Python
$framework_path/Versions/Current/bin/python$python_version"

for TESTFILE in $KEY_BINARIES; do
ARCH_TEST=$(file "$TESTFILE" | grep "2 architectures")
if [ "$ARCH_TEST" == "" ]; then
echo "$TESTFILE is not universal!"
STATUS=1
fi
done

# The Python.app binary may have been moved by make_app.sh (line 40) before
# this script runs, so skip it with a warning if absent.
PYTHON_APP_BINARY="$framework_path/Versions/$python_version/Resources/Python.app/Contents/MacOS/Python"
if [ ! -f "$PYTHON_APP_BINARY" ]; then
echo "Warning: $PYTHON_APP_BINARY not found (likely moved by make_app.sh), skipping."
else
ARCH_TEST=$(file "$PYTHON_APP_BINARY" | grep "2 architectures")
if [ "$ARCH_TEST" == "" ]; then
echo "$PYTHON_APP_BINARY is not universal!"
STATUS=1
fi
fi

[[ $STATUS == 0 ]] && echo "All files are universal!" || exit $STATUS
105 changes: 105 additions & 0 deletions osx/find_non_universal_wheels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Query PyPI to find packages that have macOS-specific binary wheels but lack
a universal2 variant for the given Python version.

Outputs a comma-separated list suitable for pip's ``--no-binary`` flag.
Diagnostic details are printed to stderr.

Usage: find_non_universal_wheels.py <python_base_version> <constraints_file>
Example: find_non_universal_wheels.py 3.13 reqs/constraints.txt
"""

import json
import sys
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path


def parse_constraints(path):
"""Return {normalized_name: version} from a pip constraints file."""
result = {}
for line in Path(path).read_text().splitlines():
line = line.split("#")[0].strip()
if not line or line.startswith("-"):
continue
if "==" in line:
name, version = line.split("==", 1)
result[name.strip().lower()] = version.strip()
return result


def check_package(name, version, py_version):
"""Return the package name if it needs ``--no-binary``, else ``None``.

A package needs ``--no-binary`` when PyPI hosts macOS wheels for our
CPython version (or stable ABI) but none of them are universal2.
Packages with *no* macOS wheels at all are fine — they are either pure
Python or will be built from source automatically.
"""
url = f"https://pypi.org/pypi/{name}/{version}/json"
try:
req = urllib.request.Request(url, headers={"Accept": "application/json"})
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read())
except Exception:
return None # Network error — assume OK; check_universal.sh is the safety net.

cp_tag = f"cp{py_version.replace('.', '')}"
has_macos_wheel = False
has_universal2 = False

for file_info in data.get("urls", []):
fn = file_info.get("filename", "")
if "macosx" not in fn:
continue
# Must be compatible with our Python version (exact cpXY or stable ABI).
if cp_tag not in fn and "abi3" not in fn:
continue
has_macos_wheel = True
if "universal2" in fn:
has_universal2 = True
break

if has_macos_wheel and not has_universal2:
return name
return None


def main():
if len(sys.argv) != 3:
print(
f"Usage: {sys.argv[0]} <python_base_version> <constraints_file>",
file=sys.stderr,
)
sys.exit(1)

py_version = sys.argv[1] # e.g. "3.13"
constraints = parse_constraints(sys.argv[2])

no_binary = []
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {
pool.submit(check_package, name, version, py_version): name
for name, version in constraints.items()
}
for future in as_completed(futures):
result = future.result()
if result:
no_binary.append(result)

no_binary.sort()
for pkg in no_binary:
version = constraints.get(pkg, "?")
print(
f" {pkg}=={version}: no universal2 wheel on PyPI, will build from source",
file=sys.stderr,
)

# stdout: comma-separated list consumed by make_app.sh
if no_binary:
print(",".join(no_binary))


if __name__ == "__main__":
main()
30 changes: 29 additions & 1 deletion osx/make_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,36 @@ run_eval "export SSL_CERT_FILE='$SSL_CERT_FILE'"
run_eval "unset __PYVENV_LAUNCHER__"
python='appdir_python'

# Ensure pip prefers universal2 wheels and source builds target both architectures.
export _PYTHON_HOST_PLATFORM="macosx-${py_installer_macos}.0-universal2"
export ARCHFLAGS="-arch x86_64 -arch arm64"

# Remove single-architecture macOS wheels from the cache. The tox dev environment
# shares .cache/wheels/ and populates it with host-arch-only wheels during its own
# dependency installation, before make_app.sh runs. Removing them forces pip to
# re-download universal2 wheels or rebuild from source with ARCHFLAGS.
for whl in .cache/wheels/*.whl; do
[ -f "$whl" ] || continue
case "$(basename "$whl")" in
*universal2*) ;;
*macosx*) echo "Removing single-arch cached wheel: $whl"; rm -f "$whl" ;;
esac
done

# Determine which packages lack universal2 wheels and must be built from source.
echo "Checking PyPI for universal2 wheel availability..."
no_binary_list=$(python3 osx/find_non_universal_wheels.py "${py_version%.*}" reqs/constraints.txt)
extra_args=(--no-cache-dir)
if [ -n "$no_binary_list" ]; then
echo "Packages requiring source builds for universal2: $no_binary_list"
extra_args+=(--no-binary "$no_binary_list")
fi

# Install Plover and dependencies.
bootstrap_dist "$plover_wheel"
bootstrap_dist "$plover_wheel" "${extra_args[@]}"

# Verify all installed binaries are universal.
run bash osx/check_universal.sh "$frameworks_dir/Python.framework" "${py_version%.*}"

# Create launcher.
run gcc -Wall -O2 -arch x86_64 -arch arm64 'osx/app_resources/plover_launcher.c' -o "$macos_dir/Plover"
Expand Down
2 changes: 2 additions & 0 deletions osx/notarize_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ cat >"$ent_plist" <<'EOS'
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key><true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
<key>com.apple.security.cs.disable-library-validation</key><true/>
</dict>
</plist>
Expand Down
22 changes: 20 additions & 2 deletions plover_build_utils/install_wheels.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ def _split_opts(text):
"""
)

# Allowed `pip wheel` only options (not passed to `pip install`).
_PIP_WHEEL_OPTS = _split_opts(
"""
--no-binary 1
--only-binary 1
"""
)

# Allowed `pip install` only options.
_PIP_INSTALL_OPTS = _split_opts(
"""
Expand Down Expand Up @@ -121,16 +129,26 @@ def install_wheels(
if opt in _PIP_OPTS:
nb_args = _PIP_OPTS[opt]
install_only = False
wheel_only = False
elif opt in _PIP_WHEEL_OPTS:
nb_args = _PIP_WHEEL_OPTS[opt]
install_only = False
wheel_only = True
elif opt in _PIP_INSTALL_OPTS:
nb_args = _PIP_INSTALL_OPTS[opt]
install_only = True
wheel_only = False
else:
raise ValueError("unsupported option: %s" % opt)
a = [opt] + args[:nb_args]
del args[:nb_args]
if not install_only:
if wheel_only:
wheel_args.extend(a)
elif not install_only:
wheel_args.extend(a)
install_args.extend(a)
install_args.extend(a)
else:
install_args.extend(a)
wheel_args[0:0] = ["wheel", "-f", wheels_cache, "-w", wheels_cache]
install_args[0:0] = ["install", "--no-index", "--no-cache-dir", "-f", wheels_cache]
pip_kwargs = dict(pip_install=pip_install, no_progress=no_progress)
Expand Down