import os import shlex import shutil import subprocess import sys import tempfile from pathlib import Path from typing import Dict, List, Optional, NamedTuple, Union from .environment import ParsedEnvironment from .util import ( BuildOptions, BuildSelector, download, get_build_verbosity_extra_flags, get_pip_script, prepare_command, ) def call(args: Union[str, List[str]], env: Optional[Dict[str, str]] = None, cwd: Optional[str] = None, shell: bool = False) -> int: # print the command executing for the logs if shell: print(f'+ {args}') else: print('+ ' + ' '.join(shlex.quote(a) for a in args)) return subprocess.check_call(args, env=env, cwd=cwd, shell=shell) class PythonConfiguration(NamedTuple): version: str identifier: str url: str def get_python_configurations(build_selector: BuildSelector) -> List[PythonConfiguration]: python_configurations = [ # CPython PythonConfiguration(version='2.7', identifier='cp27-macosx_x86_64', url='https://www.python.org/ftp/python/2.7.18/python-2.7.18-macosx10.9.pkg'), PythonConfiguration(version='3.5', identifier='cp35-macosx_x86_64', url='https://www.python.org/ftp/python/3.5.4/python-3.5.4-macosx10.6.pkg'), PythonConfiguration(version='3.6', identifier='cp36-macosx_x86_64', url='https://www.python.org/ftp/python/3.6.8/python-3.6.8-macosx10.9.pkg'), PythonConfiguration(version='3.7', identifier='cp37-macosx_x86_64', url='https://www.python.org/ftp/python/3.7.7/python-3.7.7-macosx10.9.pkg'), PythonConfiguration(version='3.8', identifier='cp38-macosx_x86_64', url='https://www.python.org/ftp/python/3.8.3/python-3.8.3-macosx10.9.pkg'), # PyPy PythonConfiguration(version='2.7', identifier='pp27-macosx_x86_64', url='https://downloads.python.org/pypy/pypy2.7-v7.3.1-osx64.tar.bz2'), PythonConfiguration(version='3.6', identifier='pp36-macosx_x86_64', url='https://downloads.python.org/pypy/pypy3.6-v7.3.1-osx64.tar.bz2'), ] # skip builds as required return [c for c in python_configurations if build_selector(c.identifier)] SYMLINKS_DIR = Path('/tmp/cibw_bin') def make_symlinks(installation_bin_path: Path, python_executable: str, pip_executable: str) -> None: assert (installation_bin_path / python_executable).exists() # Python bin folders on Mac don't symlink `python3` to `python`, and neither # does PyPy for `pypy` or `pypy3`, so we do that so `python` and `pip` always # point to the active configuration. if SYMLINKS_DIR.exists(): shutil.rmtree(SYMLINKS_DIR) SYMLINKS_DIR.mkdir(parents=True) SYMLINKS_DIR.joinpath('python').symlink_to(installation_bin_path / python_executable) SYMLINKS_DIR.joinpath('python-config').symlink_to(installation_bin_path / (python_executable + '-config')) SYMLINKS_DIR.joinpath('pip').symlink_to(installation_bin_path / pip_executable) def install_cpython(version: str, url: str) -> Path: installed_system_packages = subprocess.check_output(['pkgutil', '--pkgs'], universal_newlines=True).splitlines() # if this version of python isn't installed, get it from python.org and install python_package_identifier = f'org.python.Python.PythonFramework-{version}' if python_package_identifier not in installed_system_packages: # download the pkg download(url, Path('/tmp/Python.pkg')) # install call(['sudo', 'installer', '-pkg', '/tmp/Python.pkg', '-target', '/']) # patch open ssl if version == '3.5': open_ssl_patch_url = f'https://github.com/mayeut/patch-macos-python-openssl/releases/download/v1.0.2u/patch-macos-python-{version}-openssl-v1.0.2u.tar.gz' download(open_ssl_patch_url, Path('/tmp/python-patch.tar.gz')) call(['sudo', 'tar', '-C', f'/Library/Frameworks/Python.framework/Versions/{version}/', '-xmf', '/tmp/python-patch.tar.gz']) installation_bin_path = Path(f'/Library/Frameworks/Python.framework/Versions/{version}/bin') python_executable = 'python3' if version[0] == '3' else 'python' pip_executable = 'pip3' if version[0] == '3' else 'pip' make_symlinks(installation_bin_path, python_executable, pip_executable) return installation_bin_path def install_pypy(version: str, url: str) -> Path: pypy_tar_bz2 = url.rsplit('/', 1)[-1] extension = ".tar.bz2" assert pypy_tar_bz2.endswith(extension) pypy_base_filename = pypy_tar_bz2[:-len(extension)] installation_path = Path('/tmp') / pypy_base_filename if not installation_path.exists(): downloaded_tar_bz2 = Path("/tmp") / pypy_tar_bz2 download(url, downloaded_tar_bz2) call(['tar', '-C', '/tmp', '-xf', str(downloaded_tar_bz2)]) installation_bin_path = installation_path / 'bin' python_executable = 'pypy3' if version[0] == '3' else 'pypy' pip_executable = 'pip3' if version[0] == '3' else 'pip' make_symlinks(installation_bin_path, python_executable, pip_executable) return installation_bin_path def setup_python(python_configuration: PythonConfiguration, dependency_constraint_flags: List[str], environment: ParsedEnvironment) -> Dict[str, str]: if python_configuration.identifier.startswith('cp'): installation_bin_path = install_cpython(python_configuration.version, python_configuration.url) elif python_configuration.identifier.startswith('pp'): installation_bin_path = install_pypy(python_configuration.version, python_configuration.url) else: raise ValueError("Unknown Python implementation") env = os.environ.copy() env['PATH'] = os.pathsep.join([ str(SYMLINKS_DIR), str(installation_bin_path), env['PATH'], ]) # Fix issue with site.py setting the wrong `sys.prefix`, `sys.exec_prefix`, # `sys.path`, ... for PyPy: https://foss.heptapod.net/pypy/pypy/issues/3175 # Also fix an issue with the shebang of installed scripts inside the # testing virtualenv- see https://github.com/theacodes/nox/issues/44 and # https://github.com/pypa/virtualenv/issues/620 # Also see https://github.com/python/cpython/pull/9516 env.pop('__PYVENV_LAUNCHER__', None) env = environment.as_dictionary(prev_environment=env) # check what version we're on call(['which', 'python'], env=env) call(['python', '--version'], env=env) which_python = subprocess.check_output(['which', 'python'], env=env, universal_newlines=True).strip() if which_python != '/tmp/cibw_bin/python': print("cibuildwheel: python available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert python above it.", file=sys.stderr) exit(1) # install pip & wheel call(['python', str(get_pip_script)] + dependency_constraint_flags, env=env, cwd="/tmp") assert (installation_bin_path / 'pip').exists() call(['which', 'pip'], env=env) call(['pip', '--version'], env=env) which_pip = subprocess.check_output(['which', 'pip'], env=env, universal_newlines=True).strip() if which_pip != '/tmp/cibw_bin/pip': print("cibuildwheel: pip available on PATH doesn't match our installed instance. If you have modified PATH, ensure that you don't overwrite cibuildwheel's entry or insert pip above it.", file=sys.stderr) exit(1) call(['pip', 'install', '--upgrade', 'setuptools', 'wheel', 'delocate'] + dependency_constraint_flags, env=env) # Set MACOSX_DEPLOYMENT_TARGET to 10.9, if the user didn't set it. # CPython 3.5 defaults to 10.6, and pypy defaults to 10.7, causing # inconsistencies if it's left unset. env.setdefault('MACOSX_DEPLOYMENT_TARGET', '10.9') if python_configuration.version == '3.5': # Cross-compilation platform override - CPython 3.5 has an # i386/x86_64 version of Python, but we only want a x64_64 build env.setdefault('_PYTHON_HOST_PLATFORM', 'macosx-10.9-x86_64') # https://github.com/python/cpython/blob/a5ed2fe0eedefa1649aa93ee74a0bafc8e628a10/Lib/_osx_support.py#L260 env.setdefault('ARCHFLAGS', '-arch x86_64') return env def build(options: BuildOptions) -> None: temp_dir = Path(tempfile.mkdtemp(prefix='cibuildwheel')) built_wheel_dir = temp_dir / 'built_wheel' repaired_wheel_dir = temp_dir / 'repaired_wheel' python_configurations = get_python_configurations(options.build_selector) for config in python_configurations: dependency_constraint_flags = [] if options.dependency_constraints: dependency_constraint_flags = [ '-c', str(options.dependency_constraints.get_for_python_version(config.version)) ] env = setup_python(config, dependency_constraint_flags, options.environment) # run the before_build command if options.before_build: before_build_prepared = prepare_command(options.before_build, project='.', package=options.package_dir) call(before_build_prepared, env=env, shell=True) # build the wheel if built_wheel_dir.exists(): shutil.rmtree(built_wheel_dir) built_wheel_dir.mkdir(parents=True) # Path.resolve() is needed. Without it pip wheel may try to fetch package from pypi.org # see https://github.com/joerick/cibuildwheel/pull/369 call(['pip', 'wheel', str(options.package_dir.resolve()), '-w', str(built_wheel_dir), '--no-deps'] + get_build_verbosity_extra_flags(options.build_verbosity), env=env) built_wheel = next(built_wheel_dir.glob('*.whl')) # repair the wheel if repaired_wheel_dir.exists(): shutil.rmtree(repaired_wheel_dir) repaired_wheel_dir.mkdir(parents=True) if built_wheel.name.endswith('none-any.whl') or not options.repair_command: # pure Python wheel or empty repair command built_wheel.rename(repaired_wheel_dir / built_wheel.name) else: repair_command_prepared = prepare_command(options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir) call(repair_command_prepared, env=env, shell=True) repaired_wheel = next(repaired_wheel_dir.glob('*.whl')) if options.test_command: # set up a virtual environment to install and test from, to make sure # there are no dependencies that were pulled in at build time. call(['pip', 'install', 'virtualenv'] + dependency_constraint_flags, env=env) venv_dir = Path(tempfile.mkdtemp()) # Use --no-download to ensure determinism by using seed libraries # built into virtualenv call(['python', '-m', 'virtualenv', '--no-download', str(venv_dir)], env=env) virtualenv_env = env.copy() virtualenv_env['PATH'] = os.pathsep.join([ str(venv_dir / 'bin'), virtualenv_env['PATH'], ]) # check that we are using the Python from the virtual environment call(['which', 'python'], env=virtualenv_env) if options.before_test: before_test_prepared = prepare_command(options.before_test, project='.', package=options.package_dir) call(before_test_prepared, env=virtualenv_env, shell=True) # install the wheel call(['pip', 'install', str(repaired_wheel) + options.test_extras], env=virtualenv_env) # test the wheel if options.test_requires: call(['pip', 'install'] + options.test_requires, env=virtualenv_env) # run the tests from $HOME, with an absolute path in the command # (this ensures that Python runs the tests against the installed wheel # and not the repo code) test_command_prepared = prepare_command( options.test_command, project=Path('.').resolve(), package=options.package_dir.resolve() ) call(test_command_prepared, cwd=os.environ['HOME'], env=virtualenv_env, shell=True) # clean up shutil.rmtree(venv_dir) # we're all done here; move it to output (overwrite existing) repaired_wheel.replace(options.output_dir / repaired_wheel.name)