diff --git a/.circleci/prepare.sh b/.circleci/prepare.sh index dfd3aeef..7256c8c3 100644 --- a/.circleci/prepare.sh +++ b/.circleci/prepare.sh @@ -1,6 +1,8 @@ +set -o errexit +set -o xtrace + $PYTHON --version $PYTHON -m pip --version -$PYTHON -m pip install -q --user --ignore-installed --upgrade virtualenv $PYTHON -m virtualenv -p $PYTHON venv venv/bin/python -m pip install -r requirements-dev.txt venv/bin/python -m pip freeze diff --git a/.github/auto-merge.yml b/.github/auto-merge.yml new file mode 100644 index 00000000..a461ed1d --- /dev/null +++ b/.github/auto-merge.yml @@ -0,0 +1,7 @@ +# config file for https://github.com/bobvanderlinden/probot-auto-merge + +# no approvals required, just the label is enough +minApprovals: {} + +requiredLabels: +- merge when green diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b2e28e08..41c02da4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,6 +17,7 @@ jobs: matrix: os: [ubuntu-18.04, windows-latest, macos-latest] python_version: ['3.7'] + timeout-minutes: 180 steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..15c843f9 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,21 @@ +repos: +- repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.1 + hooks: + - id: flake8 + pass_filenames: true + # this seems to need to be here in addition to setup.cfg + exclude: cibuildwheel/resources/ +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v2.3.0 + hooks: + - id: check-yaml + - id: check-merge-conflict + - id: end-of-file-fixer + exclude: cibuildwheel/resources/pinned_docker_images.cfg +- repo: https://github.com/pre-commit/mirrors-mypy + rev: '' # Use the sha / tag you want to point at + hooks: + - id: mypy + files: cibuildwheel|test + exclude: cibuildwheel/resources diff --git a/README.md b/README.md index 6842b249..e149eb99 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,13 @@ What does it do? | CPython 3.6 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅² | | CPython 3.7 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅² | | CPython 3.8 | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅² | +| CPython 3.9³ | 🛠 | 🛠 | 🛠 | 🛠 | 🛠 | 🛠 | 🛠 | | | PyPy 2.7 v7.3.0 | ✅ | | ✅ | ✅ | | | | | | PyPy 3.6 v7.3.0 | ✅ | | ✅ | ✅ | | | | | ¹ Not supported on Travis
-² Beta support until Travis CI fixes a bug +² Beta support until Travis CI fixes a bug
+³ Python 3.9 is not yet ABI stable, so you shouldn't publish wheels with it yet. But if you want to check that your wheels build on Python 3.9, try our [`python3.9` branch](https://github.com/joerick/cibuildwheel/pull/382)! - Builds manylinux, macOS and Windows wheels for CPython and PyPy using Azure Pipelines, Travis CI, AppVeyor, and CircleCI - Bundles shared library dependencies on Linux and macOS through [auditwheel](https://github.com/pypa/auditwheel) and [delocate](https://github.com/matthew-brett/delocate) @@ -77,7 +79,7 @@ env: # Note: TWINE_PASSWORD is set to a PyPI API token in Travis settings install: - - python3 -m pip install cibuildwheel==1.4.2 + - python3 -m pip install cibuildwheel==1.5.5 script: # build the wheels, put them into './wheelhouse' @@ -92,7 +94,7 @@ after_success: fi ``` -For more information, including how to build on Appveyor, Azure, CircleCI, check out the [documentation](https://cibuildwheel.readthedocs.org) and also check out [the examples](https://github.com/joerick/cibuildwheel/tree/master/examples). +For more information, including how to build on GitHub Actions, Appveyor, Azure Pipelines, or CircleCI, check out the [documentation](https://cibuildwheel.readthedocs.org) and the [examples](https://github.com/joerick/cibuildwheel/tree/master/examples). Options ------- @@ -100,17 +102,20 @@ Options | | Option | Description | |---|--------|-------------| | **Build selection** | [`CIBW_PLATFORM`](https://cibuildwheel.readthedocs.io/en/stable/options/#platform) | Override the auto-detected target platform | -| | [`CIBW_BUILD`](https://cibuildwheel.readthedocs.io/en/stable/options/#build-skip) [`CIBW_SKIP`](https://cibuildwheel.readthedocs.io/en/stable/options/#build-skip) | Choose the Python versions to build | -| **Build environment** | [`CIBW_ENVIRONMENT`](https://cibuildwheel.readthedocs.io/en/stable/options/#environment) | Set environment variables needed during the build | +| | [`CIBW_BUILD`](https://cibuildwheel.readthedocs.io/en/stable/options/#build-skip)
[`CIBW_SKIP`](https://cibuildwheel.readthedocs.io/en/stable/options/#build-skip) | Choose the Python versions to build | +| **Build customization** | [`CIBW_ENVIRONMENT`](https://cibuildwheel.readthedocs.io/en/stable/options/#environment) | Set environment variables needed during the build | +| | [`CIBW_BEFORE_ALL`](https://cibuildwheel.readthedocs.io/en/stable/options/#before-all) | Execute a shell command on the build system before any wheels are built. | | | [`CIBW_BEFORE_BUILD`](https://cibuildwheel.readthedocs.io/en/stable/options/#before-build) | Execute a shell command preparing each wheel's build | | | [`CIBW_REPAIR_WHEEL_COMMAND`](https://cibuildwheel.readthedocs.io/en/stable/options/#repair-wheel-command) | Execute a shell command to repair each (non-pure Python) built wheel | -| | [`CIBW_MANYLINUX_X86_64_IMAGE`](https://cibuildwheel.readthedocs.io/en/stable/options/#manylinux-image) [`CIBW_MANYLINUX_I686_IMAGE`](https://cibuildwheel.readthedocs.io/en/stable/options/#manylinux-image) [`CIBW_MANYLINUX_PYPY_X86_64_IMAGE`](https://cibuildwheel.readthedocs.io/en/stable/options/#manylinux-image) | Specify alternative manylinux docker images | +| | [`CIBW_MANYLINUX_X86_64_IMAGE`](https://cibuildwheel.readthedocs.io/en/stable/options/#manylinux-image)
[`CIBW_MANYLINUX_I686_IMAGE`](https://cibuildwheel.readthedocs.io/en/stable/options/#manylinux-image)
[`CIBW_MANYLINUX_PYPY_X86_64_IMAGE`](https://cibuildwheel.readthedocs.io/en/stable/options/#manylinux-image)
[`CIBW_MANYLINUX_AARCH64_IMAGE`](https://cibuildwheel.readthedocs.io/en/stable/options/#manylinux-image)
[`CIBW_MANYLINUX_PPC64LE_IMAGE`](https://cibuildwheel.readthedocs.io/en/stable/options/#manylinux-image)
[`CIBW_MANYLINUX_S390X_IMAGE`](https://cibuildwheel.readthedocs.io/en/stable/options/#manylinux-image) | Specify alternative manylinux docker images | +| | [`CIBW_DEPENDENCY_VERSIONS`](https://cibuildwheel.readthedocs.io/en/stable/options/#dependency-versions) | Specify how cibuildwheel controls the versions of the tools it uses | | **Testing** | [`CIBW_TEST_COMMAND`](https://cibuildwheel.readthedocs.io/en/stable/options/#test-command) | Execute a shell command to test each built wheel | -| | [`CIBW_BEFORE_TEST`](https://cibuildwheel.readthedocs.io/en/stable/options/#before-test) | Execute shell command to prepare test environment | +| | [`CIBW_BEFORE_TEST`](https://cibuildwheel.readthedocs.io/en/stable/options/#before-test) | Execute a shell command before testing each wheel | | | [`CIBW_TEST_REQUIRES`](https://cibuildwheel.readthedocs.io/en/stable/options/#test-requires) | Install Python dependencies before running the tests | | | [`CIBW_TEST_EXTRAS`](https://cibuildwheel.readthedocs.io/en/stable/options/#test-extras) | Install your wheel for testing using extras_require | | **Other** | [`CIBW_BUILD_VERBOSITY`](https://cibuildwheel.readthedocs.io/en/stable/options/#build-verbosity) | Increase/decrease the output of pip wheel | + Working examples ---------------- @@ -147,6 +152,62 @@ This is similar to static linking, so it might have some licence implications. C Changelog ========= +### 1.5.5 + +_22 July 2020_ + +- 🐛 Fix a bug that would cause command substitutions in CIBW_ENVIRONMENT to + produce no output on Linux (#411) +- 🐛 Fix regression (introduced in 1.5.3) which caused BEFORE_BUILD and + BEFORE_ALL to be executed in the wrong directory (#410) + +### 1.5.4 + +_19 June 2020_ + +- 🐛 Fix a bug that would cause command substitutions in CIBW_ENVIRONMENT + variables to not interpret quotes in commands correctly (#406, #408) + +### 1.5.3 + +_19 July 2020_ + +- 🛠 Update CPython 3.8 to 3.8.3 (#405) +- 🛠 Internal refactoring of Linux build, to move control flow into Python (#386) + +### 1.5.2 + +_8 July 2020_ + +- 🐛 Fix an issue on Windows where pyproject.toml would cause an error when + some requirements formats were used. (#401) +- 🛠 Update CPython 3.7 to 3.7.8 (#394) + +### 1.5.1 + +_25 June 2020_ + +- 🐛 Fix "OSError: [WinError 17] The system cannot move the file to a different + disk drive" on Github Actions (#388, #389) + +### 1.5.0 + +_24 June 2020_ + +- 🌟 Add [`CIBW_BEFORE_ALL`](https://cibuildwheel.readthedocs.io/en/stable/options/#before-all) + option, which lets you run a command on the build machine before any wheels + are built. This is especially useful when building on Linux, to `make` + something external to Python, or to `yum install` a dependency. (#342) +- ✨ Added support for projects using pyproject.toml instead of setup.py + (#360, #358) +- ✨ Added workaround to allow Python 3.5 on Windows to pull dependencies from + pyproject.toml. (#358) +- 📚 Improved Github Actions examples and docs (#354, #362) +- 🐛 Ensure pip wheel uses the specified package, and doesn't build a wheel + from PyPI (#369) +- 🛠 Internal changes: using pathlib.Path, precommit hooks, testing + improvements. + ### 1.4.2 _25 May 2020_ @@ -169,7 +230,7 @@ _4 May 2020_ _2 May 2020_ -- ✨ Deterministic builds. cibuildwheel now locks the versions of the tools it +- 🌟 Deterministic builds. cibuildwheel now locks the versions of the tools it uses. This means that pinning your version of cibuildwheel pins the versions of pip, setuptools, manylinux etc. that are used under the hood. This should make things more reliable. But note that we don't control the entire build @@ -199,7 +260,7 @@ _2 May 2020_ _12 March 2020_ -- ✨ Add support for building on Github Actions! Check out the +- 🌟 Add support for building on Github Actions! Check out the [docs](https://cibuildwheel.readthedocs.io/en/stable/setup/#github-actions) for information on how to set it up. (#194) - ✨ Add the `CIBW_BEFORE_TEST` option, which lets you run a command to @@ -209,9 +270,9 @@ _12 March 2020_ _8 March 2020_ -- ✨ Add support for building PyPy wheels, across Manylinux, macOS, and +- 🌟 Add support for building PyPy wheels, across Manylinux, macOS, and Windows. (#185) -- ✨ Added the ability to build ARM64 (aarch64), ppc64le, and s390x wheels, +- 🌟 Added the ability to build ARM64 (aarch64), ppc64le, and s390x wheels, using manylinux2014 and Travis CI. (#273) - ✨ You can now build macOS wheels on Appveyor. (#230) - 🛠 Changed default macOS minimum target to 10.9, from 10.6. This allows the @@ -232,7 +293,7 @@ _8 March 2020_ _7 December 2019_ -- ✨ Add support for building manylinux2014 wheels. To use, set +- 🌟 Add support for building manylinux2014 wheels. To use, set `CIBW_MANYLINUX_X86_64_IMAGE` and CIBW_MANYLINUX_I686_IMAGE to `manylinux2014`. - ✨ Add support for [Linux on Appveyor](https://www.appveyor.com/blog/2018/03/06/appveyor-for-linux/) (#204, #207) @@ -245,8 +306,8 @@ _7 December 2019_ _10 November 2019_ -- ✨ Add support for building Python 3.8 wheels! (#180) -- ✨ Add support for building manylinux2010 wheels. cibuildwheel will now +- 🌟 Add support for building Python 3.8 wheels! (#180) +- 🌟 Add support for building manylinux2010 wheels. cibuildwheel will now build using the manylinux2010 images by default. If your project is still manylinux1 compatible, you should get both manylinux1 and manylinux2010 wheels - you can upload both to PyPI. If you always require manylinux1 wheels, you can @@ -282,7 +343,7 @@ _28 May 2019_ _26 May 2019_ -- ✨ Add support for building on Azure pipelines! This lets you build all +- 🌟 Add support for building on Azure pipelines! This lets you build all Linux, Mac and Windows wheels on one service, so it promises to be the easiest to set up! Check out the quickstart in the docs, or [cibuildwheel-azure-example](https://github.com/joerick/cibuildwheel-azure-example) @@ -312,8 +373,8 @@ _3 February 2019_ _23 September 2018_ -- ✨ Add `CIBW_BUILD` option, for specifying which specific builds to perform (#101) -- ✨ Add support for building Mac and Linux on CircleCI (#91, #97) +- 🌟 Add `CIBW_BUILD` option, for specifying which specific builds to perform (#101) +- 🌟 Add support for building Mac and Linux on CircleCI (#91, #97) - 🛠 Improved support for building universal wheels (#95) - 🛠 Ensure log output is unbuffered and therefore in the correct order (#92) - 🛠 Improved error reporting for errors that occur inside a package's setup.py (#88) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 4fb53b11..74dc215d 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -21,6 +21,7 @@ jobs: - job: windows_36 pool: {vmImage: 'vs2017-win2016'} + timeoutInMinutes: 180 steps: - task: UsePythonVersion@0 inputs: @@ -33,6 +34,7 @@ jobs: - job: windows_38 pool: {vmImage: 'vs2017-win2016'} + timeoutInMinutes: 180 steps: - task: UsePythonVersion@0 inputs: @@ -42,4 +44,3 @@ jobs: - bash: | python -m pip install -r requirements-dev.txt pytest-azurepipelines python ./bin/run_tests.py - diff --git a/bin/run_tests.py b/bin/run_tests.py index 93c23e88..17f06aa5 100755 --- a/bin/run_tests.py +++ b/bin/run_tests.py @@ -3,13 +3,18 @@ import os import subprocess import sys +from pathlib import Path if __name__ == '__main__': # move cwd to the project root - os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + os.chdir(Path(__file__).resolve().parents[1]) # run the unit tests - subprocess.check_call([sys.executable, '-m', 'pytest', 'unit_test']) + unit_test_args = [sys.executable, '-m', 'pytest', 'unit_test'] + # run the docker unit tests only on Linux + if sys.platform.startswith('linux'): + unit_test_args += ['--run-docker'] + subprocess.check_call(unit_test_args) # run the integration tests subprocess.check_call([sys.executable, '-m', 'pytest', '-x', '--durations', '0', 'test']) diff --git a/cibuildwheel/__init__.py b/cibuildwheel/__init__.py index 98d186be..63a58789 100644 --- a/cibuildwheel/__init__.py +++ b/cibuildwheel/__init__.py @@ -1 +1 @@ -__version__ = '1.4.2' +__version__ = '1.5.5' diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 40241ac1..4e25befa 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -4,6 +4,7 @@ import sys import textwrap import traceback from configparser import ConfigParser +from pathlib import Path from typing import Any, Dict, List, Optional, overload @@ -20,6 +21,7 @@ from cibuildwheel.util import ( BuildSelector, DependencyConstraints, Unbuffered, + resources_dir, ) @@ -114,8 +116,8 @@ def main() -> None: file=sys.stderr) exit(2) - package_dir = args.package_dir - output_dir = args.output_dir + package_dir = Path(args.package_dir) + output_dir = Path(args.output_dir) if platform == 'linux': repair_command_default = 'auditwheel repair -w {dest_dir} {wheel}' @@ -126,6 +128,7 @@ def main() -> None: build_config, skip_config = os.environ.get('CIBW_BUILD', '*'), os.environ.get('CIBW_SKIP', '') environment_config = get_option_from_environment('CIBW_ENVIRONMENT', platform=platform, default='') + before_all = get_option_from_environment('CIBW_BEFORE_ALL', platform=platform, default='') before_build = get_option_from_environment('CIBW_BEFORE_BUILD', platform=platform) repair_command = get_option_from_environment('CIBW_REPAIR_WHEEL_COMMAND', platform=platform, default=repair_command_default) dependency_versions = get_option_from_environment('CIBW_DEPENDENCY_VERSIONS', platform=platform, default='pinned') @@ -149,7 +152,8 @@ def main() -> None: elif dependency_versions == 'latest': dependency_constraints = None else: - dependency_constraints = DependencyConstraints(dependency_versions) + dependency_versions_path = Path(dependency_versions) + dependency_constraints = DependencyConstraints(dependency_versions_path) if test_extras: test_extras = f'[{test_extras}]' @@ -163,7 +167,7 @@ def main() -> None: # This needs to be passed on to the docker container in linux.py os.environ['CIBUILDWHEEL'] = '1' - if not any(os.path.exists(os.path.join(package_dir, name)) + if not any((package_dir / name).exists() for name in ["setup.py", "setup.cfg", "pyproject.toml"]): print('cibuildwheel: Could not find setup.py, setup.cfg or pyproject.toml at root of package', file=sys.stderr) exit(2) @@ -174,9 +178,7 @@ def main() -> None: manylinux_images: Optional[Dict[str, str]] = None if platform == 'linux': - pinned_docker_images_file = os.path.join( - os.path.dirname(__file__), 'resources', 'pinned_docker_images.cfg' - ) + pinned_docker_images_file = resources_dir / 'pinned_docker_images.cfg' all_pinned_docker_images = ConfigParser() all_pinned_docker_images.read(pinned_docker_images_file) # all_pinned_docker_images looks like a dict of dicts, e.g. @@ -211,6 +213,7 @@ def main() -> None: test_extras=test_extras, before_test=before_test, before_build=before_build, + before_all=before_all, build_verbosity=build_verbosity, build_selector=build_selector, repair_command=repair_command, @@ -224,8 +227,8 @@ def main() -> None: print_preamble(platform, build_options) - if not os.path.exists(output_dir): - os.makedirs(output_dir) + if not output_dir.exists(): + output_dir.mkdir(parents=True) if platform == 'linux': cibuildwheel.linux.build(build_options) diff --git a/cibuildwheel/bashlex_eval.py b/cibuildwheel/bashlex_eval.py index 60ce9a81..2c61bc54 100644 --- a/cibuildwheel/bashlex_eval.py +++ b/cibuildwheel/bashlex_eval.py @@ -1,17 +1,23 @@ -import shlex import subprocess - -from typing import Dict, NamedTuple +from typing import Callable, Dict, List, NamedTuple, Optional, Sequence import bashlex # type: ignore +# a function that takes a command and the environment, and returns the result +EnvironmentExecutor = Callable[[List[str], Dict[str, str]], str] + + +def local_environment_executor(command: List[str], env: Dict[str, str]) -> str: + return subprocess.check_output(command, env=env, universal_newlines=True) + class NodeExecutionContext(NamedTuple): environment: Dict[str, str] input: str + executor: EnvironmentExecutor -def evaluate(value: str, environment: Dict[str, str]) -> str: +def evaluate(value: str, environment: Dict[str, str], executor: Optional[EnvironmentExecutor] = None) -> str: if not value: # empty string evaluates to empty string # (but trips up bashlex) @@ -26,7 +32,7 @@ def evaluate(value: str, environment: Dict[str, str]) -> str: return evaluate_node( value_word_node, - context=NodeExecutionContext(environment=environment, input=value) + context=NodeExecutionContext(environment=environment, input=value, executor=executor or local_environment_executor) ) @@ -34,40 +40,64 @@ def evaluate_node(node: bashlex.ast.node, context: NodeExecutionContext) -> str: if node.kind == 'word': return evaluate_word_node(node, context=context) elif node.kind == 'commandsubstitution': - return evaluate_command_node(node.command, context=context) + node_result = evaluate_command_node(node.command, context=context) + # bash removes training newlines in command substitution + return node_result.rstrip() elif node.kind == 'parameter': return evaluate_parameter_node(node, context=context) else: - raise ValueError(f'Unsupported bash construct: "{node.word}"') + raise ValueError(f'Unsupported bash construct: "{node.kind}"') def evaluate_word_node(node: bashlex.ast.node, context: NodeExecutionContext) -> str: - word_start = node.pos[0] - word_end = node.pos[1] - word_string = context.input[word_start:word_end] - letters = list(word_string) + value = node.word for part in node.parts: - part_start = part.pos[0] - word_start - part_end = part.pos[1] - word_start + part_string = context.input[part.pos[0]:part.pos[1]] + part_value = evaluate_node(part, context=context) - # Set all the characters in the part to None - for i in range(part_start, part_end): - letters[i] = '' + if part_string not in value: + raise RuntimeError( + 'bash parse failed. part "{}" not found in "{}". Word was "{}". Full input was "{}"'.format( + part_string, value, node.word, context.input, + ) + ) - letters[part_start] = evaluate_node(part, context=context) + value = value.replace(part_string, part_value, 1) - # remove the None letters and concat - value = ''.join(letters) - - # apply bash-like quotes/whitespace treatment - return ' '.join(word.strip() for word in shlex.split(value)) + return value def evaluate_command_node(node: bashlex.ast.node, context: NodeExecutionContext) -> str: - words = [evaluate_node(part, context=context) for part in node.parts] - command = ' '.join(words) - return subprocess.check_output(shlex.split(command), env=context.environment, universal_newlines=True) + if any(n.kind == 'operator' for n in node.parts): + return evaluate_nodes_as_compound_command(node.parts, context=context) + else: + return evaluate_nodes_as_simple_command(node.parts, context=context) + + +def evaluate_nodes_as_compound_command(nodes: Sequence[bashlex.ast.node], context: NodeExecutionContext) -> str: + # bashlex doesn't support any operators besides ';' inside command + # substitutions, so we only need to handle that case. We do so assuming + # that `set -o errexit` is on, because it's easier to code! + + result = '' + for node in nodes: + if node.kind == 'command': + result += evaluate_command_node(node, context=context) + elif node.kind == 'operator': + if node.op == ';': + pass + else: + raise ValueError(f'Unsupported bash operator: "{node.op}"') + else: + raise ValueError(f'Unsupported bash node in compound command: "{node.kind}"') + + return result + + +def evaluate_nodes_as_simple_command(nodes: List[bashlex.ast.node], context: NodeExecutionContext): + command = [evaluate_node(part, context=context) for part in nodes] + return context.executor(command, context.environment) def evaluate_parameter_node(node: bashlex.ast.node, context: NodeExecutionContext) -> str: diff --git a/cibuildwheel/docker_container.py b/cibuildwheel/docker_container.py new file mode 100644 index 00000000..8c3a608b --- /dev/null +++ b/cibuildwheel/docker_container.py @@ -0,0 +1,192 @@ +import io +import json +import os +import shlex +import subprocess +import sys +import uuid +from os import PathLike +from pathlib import Path, PurePath +from typing import IO, Dict, List, Optional, Sequence, Union + + +class DockerContainer: + ''' + An object that represents a running Docker container. + + Intended for use as a context manager e.g. + `with DockerContainer('ubuntu') as docker:` + + A bash shell is running in the remote container. When `call()` is invoked, + the command is relayed to the remote shell, and the results are streamed + back to cibuildwheel. + ''' + UTILITY_PYTHON = '/opt/python/cp38-cp38/bin/python' + + process: subprocess.Popen + bash_stdin: IO[bytes] + bash_stdout: IO[bytes] + + def __init__(self, docker_image: str, simulate_32_bit: bool = False, cwd: Optional[Union[str, PathLike]] = None): + self.docker_image = docker_image + self.simulate_32_bit = simulate_32_bit + self.cwd = cwd + + def __enter__(self) -> 'DockerContainer': + self.name = f'cibuildwheel-{uuid.uuid4()}' + cwd_args = ['-w', str(self.cwd)] if self.cwd else [] + shell_args = ['linux32', '/bin/bash'] if self.simulate_32_bit else ['/bin/bash'] + subprocess.run( + [ + 'docker', 'create', + '--env', 'CIBUILDWHEEL', + '--name', self.name, + '-i', + '-v', '/:/host', # ignored on CircleCI + *cwd_args, + self.docker_image, + *shell_args + ], + check=True, + ) + self.process = subprocess.Popen( + [ + 'docker', 'start', + '--attach', '--interactive', + self.name, + ], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + + assert self.process.stdin and self.process.stdout + self.bash_stdin = self.process.stdin + self.bash_stdout = self.process.stdout + + # run a noop command to block until the container is responding + self.call(['/bin/true']) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.bash_stdin.close() + self.process.terminate() + self.process.wait() + + subprocess.run(['docker', 'rm', '--force', '-v', self.name]) + self.name = None + + def copy_into(self, from_path: Path, to_path: PurePath) -> None: + # `docker cp` causes 'no space left on device' error when + # a container is running and the host filesystem is + # mounted. https://github.com/moby/moby/issues/38995 + # Use `docker exec` instead. + + if from_path.is_dir(): + self.call(['mkdir', '-p', to_path]) + subprocess.run( + f'tar cf - . | docker exec -i {self.name} tar -xC {shell_quote(to_path)} -f -', + shell=True, + check=True, + cwd=from_path) + else: + subprocess.run( + f'cat {shell_quote(from_path)} | docker exec -i {self.name} sh -c "cat > {shell_quote(to_path)}"', + shell=True, + check=True) + + def copy_out(self, from_path: PurePath, to_path: Path) -> None: + # note: we assume from_path is a dir + to_path.mkdir(parents=True, exist_ok=True) + + subprocess.run( + f'docker exec -i {self.name} tar -cC {shell_quote(from_path)} -f - . | tar -xf -', + shell=True, + check=True, + cwd=to_path + ) + + def glob(self, path: PurePath, pattern: str) -> List[PurePath]: + glob_pattern = os.path.join(str(path), pattern) + + path_strs = json.loads(self.call([ + self.UTILITY_PYTHON, + '-c', + f'import sys, json, glob; json.dump(glob.glob({glob_pattern!r}), sys.stdout)' + ], capture_output=True)) + + return [PurePath(p) for p in path_strs] + + def call(self, args: Sequence[Union[str, PathLike]], env: Dict[str, str] = {}, + capture_output=False, cwd: Optional[Union[str, PathLike]] = None) -> str: + chdir = f'cd {cwd}' if cwd else '' + env_assignments = ' '.join(f'{shlex.quote(k)}={shlex.quote(v)}' + for k, v in env.items()) + command = ' '.join(shlex.quote(str(a)) for a in args) + end_of_message = str(uuid.uuid4()) + + # log the command we're executing + print(f' + {command}') + + # Write a command to the remote shell. First we change the + # cwd, if that's required. Then, we use the `env` utility to run + # `command` inside the specified environment. We use `env` because it + # can cope with spaces and strange characters in the name or value. + # Finally, the remote shell is told to write a footer - this will show + # up in the output so we know when to stop reading, and will include + # the returncode of `command`. + self.bash_stdin.write(bytes(f'''( + {chdir} + env {env_assignments} {command} + printf "%04d%s\n" $? {end_of_message} + ) + ''', encoding='utf8', errors='surrogateescape')) + self.bash_stdin.flush() + + if capture_output: + output_io: IO[bytes] = io.BytesIO() + else: + output_io = sys.stdout.buffer + + while True: + line = self.bash_stdout.readline() + + if line.endswith(b'%s\n' % (bytes(end_of_message, encoding='utf8'))): + footer_offset = ( + len(line) + - 1 # newline character + - len(end_of_message) # delimiter + - 4 # 4 returncode decimals + ) + returncode_str = line[footer_offset:footer_offset+4] + returncode = int(returncode_str) + # add the last line to output, without the footer + output_io.write(line[0:footer_offset]) + break + else: + output_io.write(line) + + if isinstance(output_io, io.BytesIO): + output = str(output_io.getvalue(), encoding='utf8', errors='surrogateescape') + else: + output = '' + + if returncode != 0: + raise subprocess.CalledProcessError(returncode, args, output) + + return output + + def get_environment(self) -> Dict[str, str]: + return json.loads(self.call([ + self.UTILITY_PYTHON, + '-c', + 'import sys, json, os; json.dump(os.environ.copy(), sys.stdout)' + ], capture_output=True)) + + def environment_executor(self, command: List[str], environment: Dict[str, str]) -> str: + # used as an EnvironmentExecutor to evaluate commands and capture output + return self.call(command, env=environment, capture_output=True) + + +def shell_quote(path: PurePath) -> str: + return shlex.quote(str(path)) diff --git a/cibuildwheel/environment.py b/cibuildwheel/environment.py index 086a253c..5ecd8c68 100644 --- a/cibuildwheel/environment.py +++ b/cibuildwheel/environment.py @@ -1,6 +1,6 @@ import bashlex # type: ignore -from typing import Dict, List +from typing import Dict, List, Mapping, Optional from . import bashlex_eval @@ -46,9 +46,9 @@ class EnvironmentAssignment: self.name = name self.value = value - def evaluated_value(self, environment: Dict[str, str]) -> str: + def evaluated_value(self, environment: Dict[str, str], executor: Optional[bashlex_eval.EnvironmentExecutor] = None) -> str: '''Returns the value of this assignment, as evaluated in the environment''' - return bashlex_eval.evaluate(self.value, environment=environment) + return bashlex_eval.evaluate(self.value, environment=environment, executor=executor) def as_shell_assignment(self) -> str: return f'export {self.name}={self.value}' @@ -61,11 +61,13 @@ class ParsedEnvironment: def __init__(self, assignments: List[EnvironmentAssignment]): self.assignments = assignments - def as_dictionary(self, prev_environment: Dict[str, str]) -> Dict[str, str]: - environment = prev_environment.copy() + def as_dictionary(self, + prev_environment: Mapping[str, str], + executor: Optional[bashlex_eval.EnvironmentExecutor] = None) -> Dict[str, str]: + environment = dict(**prev_environment) for assignment in self.assignments: - value = assignment.evaluated_value(environment=environment) + value = assignment.evaluated_value(environment=environment, executor=executor) environment[assignment.name] = value return environment diff --git a/cibuildwheel/linux.py b/cibuildwheel/linux.py index 257117c8..8be32834 100644 --- a/cibuildwheel/linux.py +++ b/cibuildwheel/linux.py @@ -1,26 +1,14 @@ -import os import platform -import shlex import subprocess import sys import textwrap -import uuid +from os import PathLike +from pathlib import Path, PurePath +from typing import List, NamedTuple, Union -from typing import List, NamedTuple, Optional, Union - -from .util import ( - BuildOptions, - BuildSelector, - get_build_verbosity_extra_flags, - prepare_command, -) - - -def call(args: List[str], input: Optional[Union[str, bytes]] = None, universal_newlines: bool = False) -> None: - print('+ ' + ' '.join(shlex.quote(a) for a in args)) - subprocess.run( - args, input=input, universal_newlines=universal_newlines, check=True - ) +from .docker_container import DockerContainer +from .util import (BuildOptions, BuildSelector, + get_build_verbosity_extra_flags, prepare_command) def matches_platform(identifier: str) -> bool: @@ -47,42 +35,46 @@ def matches_platform(identifier: str) -> bool: class PythonConfiguration(NamedTuple): version: str identifier: str - path: str + path_str: str + + @property + def path(self): + return PurePath(self.path_str) def get_python_configurations(build_selector: BuildSelector) -> List[PythonConfiguration]: python_configurations = [ - PythonConfiguration(version='2.7', identifier='cp27-manylinux_x86_64', path='/opt/python/cp27-cp27m'), - PythonConfiguration(version='2.7', identifier='cp27-manylinux_x86_64', path='/opt/python/cp27-cp27mu'), - PythonConfiguration(version='3.5', identifier='cp35-manylinux_x86_64', path='/opt/python/cp35-cp35m'), - PythonConfiguration(version='3.6', identifier='cp36-manylinux_x86_64', path='/opt/python/cp36-cp36m'), - PythonConfiguration(version='3.7', identifier='cp37-manylinux_x86_64', path='/opt/python/cp37-cp37m'), - PythonConfiguration(version='3.8', identifier='cp38-manylinux_x86_64', path='/opt/python/cp38-cp38'), - PythonConfiguration(version='3.9', identifier='cp39-manylinux_x86_64', path='/opt/python/cp39-cp39'), - PythonConfiguration(version='2.7', identifier='cp27-manylinux_i686', path='/opt/python/cp27-cp27m'), - PythonConfiguration(version='2.7', identifier='cp27-manylinux_i686', path='/opt/python/cp27-cp27mu'), - PythonConfiguration(version='3.5', identifier='cp35-manylinux_i686', path='/opt/python/cp35-cp35m'), - PythonConfiguration(version='3.6', identifier='cp36-manylinux_i686', path='/opt/python/cp36-cp36m'), - PythonConfiguration(version='3.7', identifier='cp37-manylinux_i686', path='/opt/python/cp37-cp37m'), - PythonConfiguration(version='3.8', identifier='cp38-manylinux_i686', path='/opt/python/cp38-cp38'), - PythonConfiguration(version='3.9', identifier='cp39-manylinux_i686', path='/opt/python/cp39-cp39'), - PythonConfiguration(version='2.7', identifier='pp27-manylinux_x86_64', path='/opt/python/pp27-pypy_73'), - PythonConfiguration(version='3.6', identifier='pp36-manylinux_x86_64', path='/opt/python/pp36-pypy36_pp73'), - PythonConfiguration(version='3.5', identifier='cp35-manylinux_aarch64', path='/opt/python/cp35-cp35m'), - PythonConfiguration(version='3.6', identifier='cp36-manylinux_aarch64', path='/opt/python/cp36-cp36m'), - PythonConfiguration(version='3.7', identifier='cp37-manylinux_aarch64', path='/opt/python/cp37-cp37m'), - PythonConfiguration(version='3.8', identifier='cp38-manylinux_aarch64', path='/opt/python/cp38-cp38'), - PythonConfiguration(version='3.9', identifier='cp39-manylinux_aarch64', path='/opt/python/cp39-cp39'), - PythonConfiguration(version='3.5', identifier='cp35-manylinux_ppc64le', path='/opt/python/cp35-cp35m'), - PythonConfiguration(version='3.6', identifier='cp36-manylinux_ppc64le', path='/opt/python/cp36-cp36m'), - PythonConfiguration(version='3.7', identifier='cp37-manylinux_ppc64le', path='/opt/python/cp37-cp37m'), - PythonConfiguration(version='3.8', identifier='cp38-manylinux_ppc64le', path='/opt/python/cp38-cp38'), - PythonConfiguration(version='3.9', identifier='cp39-manylinux_ppc64le', path='/opt/python/cp39-cp39'), - PythonConfiguration(version='3.5', identifier='cp35-manylinux_s390x', path='/opt/python/cp35-cp35m'), - PythonConfiguration(version='3.6', identifier='cp36-manylinux_s390x', path='/opt/python/cp36-cp36m'), - PythonConfiguration(version='3.7', identifier='cp37-manylinux_s390x', path='/opt/python/cp37-cp37m'), - PythonConfiguration(version='3.8', identifier='cp38-manylinux_s390x', path='/opt/python/cp38-cp38'), - PythonConfiguration(version='3.9', identifier='cp39-manylinux_s390x', path='/opt/python/cp39-cp39'), + PythonConfiguration(version='2.7', identifier='cp27-manylinux_x86_64', path_str='/opt/python/cp27-cp27m'), + PythonConfiguration(version='2.7', identifier='cp27-manylinux_x86_64', path_str='/opt/python/cp27-cp27mu'), + PythonConfiguration(version='3.5', identifier='cp35-manylinux_x86_64', path_str='/opt/python/cp35-cp35m'), + PythonConfiguration(version='3.6', identifier='cp36-manylinux_x86_64', path_str='/opt/python/cp36-cp36m'), + PythonConfiguration(version='3.7', identifier='cp37-manylinux_x86_64', path_str='/opt/python/cp37-cp37m'), + PythonConfiguration(version='3.8', identifier='cp38-manylinux_x86_64', path_str='/opt/python/cp38-cp38'), + PythonConfiguration(version='3.9', identifier='cp39-manylinux_x86_64', path_str='/opt/python/cp39-cp39'), + PythonConfiguration(version='2.7', identifier='cp27-manylinux_i686', path_str='/opt/python/cp27-cp27m'), + PythonConfiguration(version='2.7', identifier='cp27-manylinux_i686', path_str='/opt/python/cp27-cp27mu'), + PythonConfiguration(version='3.5', identifier='cp35-manylinux_i686', path_str='/opt/python/cp35-cp35m'), + PythonConfiguration(version='3.6', identifier='cp36-manylinux_i686', path_str='/opt/python/cp36-cp36m'), + PythonConfiguration(version='3.7', identifier='cp37-manylinux_i686', path_str='/opt/python/cp37-cp37m'), + PythonConfiguration(version='3.8', identifier='cp38-manylinux_i686', path_str='/opt/python/cp38-cp38'), + PythonConfiguration(version='3.9', identifier='cp39-manylinux_i686', path_str='/opt/python/cp39-cp39'), + PythonConfiguration(version='2.7', identifier='pp27-manylinux_x86_64', path_str='/opt/python/pp27-pypy_73'), + PythonConfiguration(version='3.6', identifier='pp36-manylinux_x86_64', path_str='/opt/python/pp36-pypy36_pp73'), + PythonConfiguration(version='3.5', identifier='cp35-manylinux_aarch64', path_str='/opt/python/cp35-cp35m'), + PythonConfiguration(version='3.6', identifier='cp36-manylinux_aarch64', path_str='/opt/python/cp36-cp36m'), + PythonConfiguration(version='3.7', identifier='cp37-manylinux_aarch64', path_str='/opt/python/cp37-cp37m'), + PythonConfiguration(version='3.8', identifier='cp38-manylinux_aarch64', path_str='/opt/python/cp38-cp38'), + PythonConfiguration(version='3.9', identifier='cp39-manylinux_aarch64', path_str='/opt/python/cp39-cp39'), + PythonConfiguration(version='3.5', identifier='cp35-manylinux_ppc64le', path_str='/opt/python/cp35-cp35m'), + PythonConfiguration(version='3.6', identifier='cp36-manylinux_ppc64le', path_str='/opt/python/cp36-cp36m'), + PythonConfiguration(version='3.7', identifier='cp37-manylinux_ppc64le', path_str='/opt/python/cp37-cp37m'), + PythonConfiguration(version='3.8', identifier='cp38-manylinux_ppc64le', path_str='/opt/python/cp38-cp38'), + PythonConfiguration(version='3.9', identifier='cp39-manylinux_ppc64le', path_str='/opt/python/cp39-cp39'), + PythonConfiguration(version='3.5', identifier='cp35-manylinux_s390x', path_str='/opt/python/cp35-cp35m'), + PythonConfiguration(version='3.6', identifier='cp36-manylinux_s390x', path_str='/opt/python/cp36-cp36m'), + PythonConfiguration(version='3.7', identifier='cp37-manylinux_s390x', path_str='/opt/python/cp37-cp37m'), + PythonConfiguration(version='3.8', identifier='cp38-manylinux_s390x', path_str='/opt/python/cp38-cp38'), + PythonConfiguration(version='3.9', identifier='cp39-manylinux_s390x', path_str='/opt/python/cp39-cp39'), ] # skip builds as required return [c for c in python_configurations if matches_platform(c.identifier) and build_selector(c.identifier)] @@ -109,193 +101,144 @@ def build(options: BuildOptions) -> None: ('pp', 'manylinux_x86_64', options.manylinux_images['pypy_x86_64']), ] - if not os.path.realpath(options.package_dir).startswith(os.path.realpath('.')): + cwd = Path.cwd() + abs_package_dir = options.package_dir.resolve() + if cwd != abs_package_dir and cwd not in abs_package_dir.parents: raise Exception('package_dir must be inside the working directory') - container_package_dir = os.path.join('/project', os.path.relpath(options.package_dir, '.')) + container_project_path = PurePath('/project') + container_package_dir = container_project_path / abs_package_dir.relative_to(cwd) + container_output_dir = PurePath('/output') for implementation, platform_tag, docker_image in platforms: platform_configs = [c for c in python_configurations if c.identifier.startswith(implementation) and c.identifier.endswith(platform_tag)] if not platform_configs: continue - shell_cmd = ['linux32', '/bin/bash'] if platform_tag.endswith("i686") else ['/bin/bash'] - - container_name = f'cibuildwheel-{uuid.uuid4()}' - call(['docker', 'create', - '--env', 'CIBUILDWHEEL', - '--name', container_name, - '-i', - '-v', '/:/host', # ignored on CircleCI - docker_image, - '/bin/bash']) - try: - call(['docker', 'cp', '.', container_name + ':/project']) + with DockerContainer(docker_image, simulate_32_bit=platform_tag.endswith('i686'), cwd=container_project_path) as docker: + docker.copy_into(Path.cwd(), container_project_path) - call(['docker', 'start', container_name]) + if options.before_all: + env = docker.get_environment() + env['PATH'] = f'/opt/python/cp38-cp38:{env["PATH"]}' + env = options.environment.as_dictionary(env, executor=docker.environment_executor) - for config in platform_configs: - if options.dependency_constraints: - constraints_file = options.dependency_constraints.get_for_python_version(config.version) + before_all_prepared = prepare_command(options.before_all, project=container_project_path, package=container_package_dir) + docker.call(['sh', '-c', before_all_prepared], env=env) - # `docker cp` causes 'no space left on device' error when - # a container is running and the host filesystem is - # mounted. https://github.com/moby/moby/issues/38995 - # Use `docker exec` instead. - with open(constraints_file, 'rb') as f: - call( - ['docker', 'exec', '-i', container_name, 'sh', '-c', 'cat > /constraints.txt'], - input=f.read(), - ) + for config in platform_configs: + dependency_constraint_flags: List[Union[str, PathLike]] = [] - call( - ['docker', 'exec', '-i', container_name] + shell_cmd, - universal_newlines=True, - input=''' - # give xtrace output an extra level of indent inside docker - PS4=' + ' + if options.dependency_constraints: + constraints_file = options.dependency_constraints.get_for_python_version(config.version) + container_constraints_file = PurePath('/constraints.txt') - set -o errexit - set -o xtrace - mkdir -p /output - cd /project + docker.copy_into(constraints_file, container_constraints_file) + dependency_constraint_flags = ['-c', container_constraints_file] - PYBIN="{config_python_bin}" + env = docker.get_environment() - export PATH="$PYBIN:$PATH" - {environment_exports} + # put this config's python top of the list + python_bin = config.path / 'bin' + env['PATH'] = f'{python_bin}:{env["PATH"]}' - # check the active python and pip are in PYBIN - if [ "$(which pip)" != "$PYBIN/pip" ]; then - echo "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." - exit 1 - fi - if [ "$(which python)" != "$PYBIN/python" ]; then - echo "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." - exit 1 - fi + env = options.environment.as_dictionary(env, executor=docker.environment_executor) - if [ ! -z {before_build} ]; then - sh -c {before_build} - fi + # check config python and pip are still on PATH + which_python = docker.call(['which', 'python'], env=env, capture_output=True).strip() + if PurePath(which_python) != python_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) - # Build the wheel - rm -rf /tmp/built_wheel - mkdir /tmp/built_wheel - pip wheel {package_dir} -w /tmp/built_wheel --no-deps {build_verbosity_flag} - built_wheel=(/tmp/built_wheel/*.whl) + which_pip = docker.call(['which', 'pip'], env=env, capture_output=True).strip() + if PurePath(which_pip) != python_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) - # repair the wheel - rm -rf /tmp/repaired_wheels - mkdir /tmp/repaired_wheels - # NOTE: 'built_wheel' here is a bash array of glob matches; "$built_wheel" returns - # the first element - if [[ "$built_wheel" == *none-any.whl ]] || [ -z {repair_command} ]; then - # pure Python wheel or empty repair command - mv "$built_wheel" /tmp/repaired_wheels - else - sh -c {repair_command} repair_command "$built_wheel" - fi - repaired_wheels=(/tmp/repaired_wheels/*.whl) + if options.before_build: + before_build_prepared = prepare_command(options.before_build, project=container_project_path, package=container_package_dir) + docker.call(['sh', '-c', before_build_prepared], env=env) - if [ ! -z {test_command} ]; then - # Set up a virtual environment to install and test from, to make sure - # there are no dependencies that were pulled in at build time. - pip install {dependency_install_flags} virtualenv - venv_dir=`mktemp -d`/venv - python -m virtualenv --no-download "$venv_dir" + temp_dir = PurePath('/tmp/cibuildwheel') + built_wheel_dir = temp_dir / 'built_wheel' + docker.call(['rm', '-rf', built_wheel_dir]) + docker.call(['mkdir', '-p', built_wheel_dir]) - # run the tests in a subshell to keep that `activate` - # script from polluting the env - ( - source "$venv_dir/bin/activate" + docker.call([ + 'pip', 'wheel', + container_package_dir, + '-w', built_wheel_dir, + '--no-deps', + *get_build_verbosity_extra_flags(options.build_verbosity) + ], env=env) - echo "Running tests using `which python`" + built_wheel = docker.glob(built_wheel_dir, '*.whl')[0] - if [ ! -z {before_test} ]; then - sh -c {before_test} - fi + repaired_wheel_dir = temp_dir / 'repaired_wheel' + docker.call(['rm', '-rf', repaired_wheel_dir]) + docker.call(['mkdir', '-p', repaired_wheel_dir]) - # Install the wheel we just built - # Note: If auditwheel produced two wheels, it's because the earlier produced wheel - # conforms to multiple manylinux standards. These multiple versions of the wheel are - # functionally the same, differing only in name, wheel metadata, and possibly include - # different external shared libraries. so it doesn't matter which one we run the tests on. - # Let's just pick the first one. - pip install "${{repaired_wheels[0]}}"{test_extras} + if built_wheel.name.endswith('none-any.whl') or not options.repair_command: + docker.call(['mv', built_wheel, repaired_wheel_dir]) + else: + repair_command_prepared = prepare_command(options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir) + docker.call(['sh', '-c', repair_command_prepared], env=env) - # Install any requirements to run the tests - if [ ! -z "{test_requires}" ]; then - pip install {test_requires} - fi + repaired_wheels = docker.glob(repaired_wheel_dir, '*.whl') - # Run the tests from a different directory - pushd $HOME - sh -c {test_command} - popd - ) - # exit if tests failed (needed for older bash versions) - if [ $? -ne 0 ]; then - exit 1; - fi + 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. + docker.call(['pip', 'install', 'virtualenv', *dependency_constraint_flags], env=env) + venv_dir = PurePath(docker.call(['mktemp', '-d'], capture_output=True).strip()) / 'venv' - # clean up - rm -rf "$venv_dir" - fi + docker.call(['python', '-m', 'virtualenv', '--no-download', venv_dir], env=env) - # we're all done here; move it to output - mv "${{repaired_wheels[@]}}" /output - for repaired_wheel in "${{repaired_wheels[@]}}"; do - chown {uid}:{gid} "/output/$(basename "$repaired_wheel")" - done - '''.format( - config_python_bin=config.path + '/bin', - package_dir=container_package_dir, - test_requires=' '.join(options.test_requires), - test_extras=options.test_extras, - test_command=shlex.quote( - prepare_command(options.test_command, project='/project', package=container_package_dir) if options.test_command else '' - ), - before_build=shlex.quote( - prepare_command(options.before_build, project='/project', package=container_package_dir) if options.before_build else '' - ), - build_verbosity_flag=' '.join(get_build_verbosity_extra_flags(options.build_verbosity)), - repair_command=shlex.quote( - prepare_command(options.repair_command, wheel='"$1"', dest_dir='/tmp/repaired_wheels') if options.repair_command else '' - ), - environment_exports='\n'.join(options.environment.as_shell_commands()), - uid=os.getuid(), - gid=os.getgid(), - before_test=shlex.quote( - prepare_command(options.before_test, project='/project', package=container_package_dir) if options.before_test else '' - ), - dependency_install_flags='-c /constraints.txt' if options.dependency_constraints else '', - ) - ) + virtualenv_env = env.copy() + virtualenv_env['PATH'] = f"{venv_dir / 'bin'}:{virtualenv_env['PATH']}" - # copy the output back into the host - call(['docker', 'cp', - container_name + ':/output/.', - os.path.abspath(options.output_dir)]) + if options.before_test: + before_test_prepared = prepare_command(options.before_test, project=container_project_path, package=container_package_dir) + docker.call(['sh', '-c', before_test_prepared], env=virtualenv_env) + + # Install the wheel we just built + # Note: If auditwheel produced two wheels, it's because the earlier produced wheel + # conforms to multiple manylinux standards. These multiple versions of the wheel are + # functionally the same, differing only in name, wheel metadata, and possibly include + # different external shared libraries. so it doesn't matter which one we run the tests on. + # Let's just pick the first one. + wheel_to_test = repaired_wheels[0] + docker.call(['pip', 'install', str(wheel_to_test) + options.test_extras], env=virtualenv_env) + + # Install any requirements to run the tests + if options.test_requires: + docker.call(['pip', 'install', *options.test_requires], env=virtualenv_env) + + # Run the tests from a different directory + test_command_prepared = prepare_command(options.test_command, project=container_project_path, package=container_package_dir) + docker.call(['sh', '-c', test_command_prepared], cwd='/root', env=virtualenv_env) + + # clean up test environment + docker.call(['rm', '-rf', venv_dir]) + + # move repaired wheels to output + docker.call(['mkdir', '-p', container_output_dir]) + docker.call(['mv', *repaired_wheels, container_output_dir]) + + # copy the output back into the host + docker.copy_out(container_output_dir, options.output_dir) except subprocess.CalledProcessError as error: + print(f'Command {error.cmd} failed with code {error.returncode}. {error.stdout}') troubleshoot(options.package_dir, error) exit(1) - finally: - # Still gets executed, even when 'exit(1)' gets called - call(['docker', 'rm', '--force', '-v', container_name]) -def troubleshoot(package_dir: str, error: Exception) -> None: +def troubleshoot(package_dir: Path, error: Exception) -> None: if (isinstance(error, subprocess.CalledProcessError) and 'exec' in error.cmd): # the bash script failed print('Checking for common errors...') - so_files = [] - for root, dirs, files in os.walk(package_dir): - for name in files: - _, ext = os.path.splitext(name) - if ext == '.so': - so_files.append(os.path.join(root, name)) + so_files = list(package_dir.glob('**/*.so')) if so_files: print(textwrap.dedent(''' @@ -309,5 +252,5 @@ def troubleshoot(package_dir: str, error: Exception) -> None: ''')) print(' Files detected:') - print('\n'.join([' ' + f for f in so_files])) + print('\n'.join([f' {f}' for f in so_files])) print('') diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py index f31fbc13..eec3c6c9 100644 --- a/cibuildwheel/macos.py +++ b/cibuildwheel/macos.py @@ -4,27 +4,22 @@ import shutil import subprocess import sys import tempfile -from glob import glob - -from typing import Dict, List, Optional, NamedTuple, Union +from os import PathLike +from pathlib import Path +from typing import Dict, List, NamedTuple, Optional, Sequence, Union from .environment import ParsedEnvironment -from .util import ( - BuildOptions, - BuildSelector, - download, - get_build_verbosity_extra_flags, - get_pip_script, - prepare_command, -) +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: +def call(args: Union[str, Sequence[Union[str, PathLike]]], 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)) + print('+ ' + ' '.join(shlex.quote(str(a)) for a in args)) return subprocess.check_call(args, env=env, cwd=cwd, shell=shell) @@ -41,8 +36,8 @@ def get_python_configurations(build_selector: BuildSelector) -> List[PythonConfi 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'), + PythonConfiguration(version='3.7', identifier='cp37-macosx_x86_64', url='https://www.python.org/ftp/python/3.7.8/python-3.7.8-macosx10.9.pkg'), + PythonConfiguration(version='3.8', identifier='cp38-macosx_x86_64', url='https://www.python.org/ftp/python/3.8.4/python-3.8.4-macosx10.9.pkg'), PythonConfiguration(version='3.9', identifier='cp39-macosx_x86_64', url='https://www.python.org/ftp/python/3.9.0/python-3.9.0b3-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'), @@ -53,41 +48,41 @@ def get_python_configurations(build_selector: BuildSelector) -> List[PythonConfi return [c for c in python_configurations if build_selector(c.identifier)] -SYMLINKS_DIR = '/tmp/cibw_bin' +SYMLINKS_DIR = Path('/tmp/cibw_bin') -def make_symlinks(installation_bin_path: str, python_executable: str, pip_executable: str) -> None: - assert os.path.exists(os.path.join(installation_bin_path, python_executable)) +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 os.path.exists(SYMLINKS_DIR): + if SYMLINKS_DIR.exists(): shutil.rmtree(SYMLINKS_DIR) - os.makedirs(SYMLINKS_DIR) + SYMLINKS_DIR.mkdir(parents=True) - os.symlink(os.path.join(installation_bin_path, python_executable), os.path.join(SYMLINKS_DIR, 'python')) - os.symlink(os.path.join(installation_bin_path, python_executable + '-config'), os.path.join(SYMLINKS_DIR, 'python-config')) - os.symlink(os.path.join(installation_bin_path, pip_executable), os.path.join(SYMLINKS_DIR, 'pip')) + (SYMLINKS_DIR / 'python').symlink_to(installation_bin_path / python_executable) + (SYMLINKS_DIR / 'python-config').symlink_to(installation_bin_path / (python_executable + '-config')) + (SYMLINKS_DIR / 'pip').symlink_to(installation_bin_path / pip_executable) -def install_cpython(version: str, url: str) -> str: +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, '/tmp/Python.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, '/tmp/python-patch.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 = f'/Library/Frameworks/Python.framework/Versions/{version}/bin' + 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) @@ -95,16 +90,18 @@ def install_cpython(version: str, url: str) -> str: return installation_bin_path -def install_pypy(version: str, url: str) -> str: +def install_pypy(version: str, url: str) -> Path: pypy_tar_bz2 = url.rsplit('/', 1)[-1] - assert pypy_tar_bz2.endswith(".tar.bz2") - pypy_base_filename = os.path.splitext(os.path.splitext(pypy_tar_bz2)[0])[0] - installation_path = os.path.join('/tmp', pypy_base_filename) - if not os.path.exists(installation_path): - download(url, os.path.join("/tmp", pypy_tar_bz2)) - call(['tar', '-C', '/tmp', '-xf', os.path.join("/tmp", pypy_tar_bz2)]) + 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', downloaded_tar_bz2]) - installation_bin_path = os.path.join(installation_path, 'bin') + 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) @@ -112,7 +109,9 @@ def install_pypy(version: str, url: str) -> str: return installation_bin_path -def setup_python(python_configuration: PythonConfiguration, dependency_constraint_flags: List[str], environment: ParsedEnvironment) -> Dict[str, str]: +def setup_python(python_configuration: PythonConfiguration, + dependency_constraint_flags: Sequence[Union[str, PathLike]], + 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'): @@ -122,8 +121,8 @@ def setup_python(python_configuration: PythonConfiguration, dependency_constrain env = os.environ.copy() env['PATH'] = os.pathsep.join([ - SYMLINKS_DIR, - installation_bin_path, + str(SYMLINKS_DIR), + str(installation_bin_path), env['PATH'], ]) @@ -145,15 +144,15 @@ def setup_python(python_configuration: PythonConfiguration, dependency_constrain exit(1) # install pip & wheel - call(['python', get_pip_script] + dependency_constraint_flags, env=env, cwd="/tmp") - assert os.path.exists(os.path.join(installation_bin_path, 'pip')) + call(['python', 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) + 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 @@ -171,14 +170,19 @@ def setup_python(python_configuration: PythonConfiguration, dependency_constrain def build(options: BuildOptions) -> None: - temp_dir = tempfile.mkdtemp(prefix='cibuildwheel') - built_wheel_dir = os.path.join(temp_dir, 'built_wheel') - repaired_wheel_dir = os.path.join(temp_dir, 'repaired_wheel') + temp_dir = Path(tempfile.mkdtemp(prefix='cibuildwheel')) + built_wheel_dir = temp_dir / 'built_wheel' + repaired_wheel_dir = temp_dir / 'repaired_wheel' + + if options.before_all: + env = options.environment.as_dictionary(prev_environment=os.environ) + before_all_prepared = prepare_command(options.before_all, project='.', package=options.package_dir) + call([before_all_prepared], shell=True, env=env) python_configurations = get_python_configurations(options.build_selector) for config in python_configurations: - dependency_constraint_flags = [] + dependency_constraint_flags: Sequence[Union[str, PathLike]] = [] if options.dependency_constraints: dependency_constraint_flags = [ '-c', options.dependency_constraints.get_for_python_version(config.version) @@ -192,31 +196,39 @@ def build(options: BuildOptions) -> None: call(before_build_prepared, env=env, shell=True) # build the wheel - if os.path.exists(built_wheel_dir): + if built_wheel_dir.exists(): shutil.rmtree(built_wheel_dir) - os.makedirs(built_wheel_dir) - # os.path.abspath is need. Without it pip wheel may try to fetch package from pypi.org + 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', os.path.abspath(options.package_dir), '-w', built_wheel_dir, '--no-deps'] + get_build_verbosity_extra_flags(options.build_verbosity), env=env) - built_wheel = glob(os.path.join(built_wheel_dir, '*.whl'))[0] + call([ + 'pip', 'wheel', + options.package_dir.resolve(), + '-w', 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 os.path.exists(repaired_wheel_dir): + if repaired_wheel_dir.exists(): shutil.rmtree(repaired_wheel_dir) - os.makedirs(repaired_wheel_dir) - if built_wheel.endswith('none-any.whl') or not options.repair_command: + 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 - shutil.move(built_wheel, repaired_wheel_dir) + shutil.move(str(built_wheel), repaired_wheel_dir) 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 = glob(os.path.join(repaired_wheel_dir, '*.whl'))[0] + 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 = tempfile.mkdtemp() + 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 @@ -224,7 +236,7 @@ def build(options: BuildOptions) -> None: virtualenv_env = env.copy() virtualenv_env['PATH'] = os.pathsep.join([ - os.path.join(venv_dir, 'bin'), + str(venv_dir / 'bin'), virtualenv_env['PATH'], ]) @@ -236,7 +248,7 @@ def build(options: BuildOptions) -> None: call(before_test_prepared, env=virtualenv_env, shell=True) # install the wheel - call(['pip', 'install', repaired_wheel + options.test_extras], env=virtualenv_env) + call(['pip', 'install', str(repaired_wheel) + options.test_extras], env=virtualenv_env) # test the wheel if options.test_requires: @@ -247,8 +259,8 @@ def build(options: BuildOptions) -> None: # and not the repo code) test_command_prepared = prepare_command( options.test_command, - project=os.path.abspath('.'), - package=os.path.abspath(options.package_dir) + project=Path('.').resolve(), + package=options.package_dir.resolve() ) call(test_command_prepared, cwd=os.environ['HOME'], env=virtualenv_env, shell=True) @@ -256,5 +268,4 @@ def build(options: BuildOptions) -> None: shutil.rmtree(venv_dir) # we're all done here; move it to output (overwrite existing) - dst = os.path.join(options.output_dir, os.path.basename(repaired_wheel)) - shutil.move(repaired_wheel, dst) + shutil.move(str(repaired_wheel), options.output_dir) diff --git a/cibuildwheel/util.py b/cibuildwheel/util.py index 499954b9..012afa1a 100644 --- a/cibuildwheel/util.py +++ b/cibuildwheel/util.py @@ -1,14 +1,15 @@ import os import urllib.request from fnmatch import fnmatch +from pathlib import Path from time import sleep -from typing import Dict, List, NamedTuple, Optional +from typing import Dict, List, NamedTuple, Optional, Union from .environment import ParsedEnvironment -def prepare_command(command: str, **kwargs: str) -> str: +def prepare_command(command: str, **kwargs: Union[str, os.PathLike]) -> str: ''' Preprocesses a command by expanding variables like {python}. @@ -58,11 +59,11 @@ class Unbuffered: return getattr(self.stream, attr) -def download(url: str, dest: str) -> None: - print('+ Download ' + url + ' to ' + dest) - dest_dir = os.path.dirname(dest) - if not os.path.exists(dest_dir): - os.makedirs(dest_dir) +def download(url: str, dest: Path) -> None: + print(f'+ Download {url} to {dest}') + dest_dir = dest.parent + if not dest_dir.exists(): + dest_dir.mkdir(parents=True) repeat_num = 3 for i in range(repeat_num): @@ -76,42 +77,42 @@ def download(url: str, dest: str) -> None: break try: - with open(dest, 'wb') as file: - file.write(response.read()) + dest.write_bytes(response.read()) finally: response.close() class DependencyConstraints: - def __init__(self, base_file_path: str): - assert os.path.exists(base_file_path) - self.base_file_path = os.path.abspath(base_file_path) + def __init__(self, base_file_path: Path): + assert base_file_path.exists() + self.base_file_path = base_file_path.resolve() @staticmethod def with_defaults() -> 'DependencyConstraints': return DependencyConstraints( - base_file_path=os.path.join(os.path.dirname(__file__), 'resources', 'constraints.txt') + base_file_path=resources_dir / 'constraints.txt' ) - def get_for_python_version(self, version: str) -> str: + def get_for_python_version(self, version: str) -> Path: version_parts = version.split('.') # try to find a version-specific dependency file e.g. if # ./constraints.txt is the base, look for ./constraints-python27.txt - base, ext = os.path.splitext(self.base_file_path) - specific = base + f'-python{version_parts[0]}{version_parts[1]}' - specific_file_path = specific + ext - if os.path.exists(specific_file_path): + specific_stem = self.base_file_path.stem + f'-python{version_parts[0]}{version_parts[1]}' + specific_name = specific_stem + self.base_file_path.suffix + specific_file_path = self.base_file_path.with_name(specific_name) + if specific_file_path.exists(): return specific_file_path else: return self.base_file_path class BuildOptions(NamedTuple): - package_dir: str - output_dir: str + package_dir: Path + output_dir: Path build_selector: BuildSelector environment: ParsedEnvironment + before_all: str before_build: Optional[str] repair_command: str manylinux_images: Optional[Dict[str, str]] @@ -123,5 +124,5 @@ class BuildOptions(NamedTuple): build_verbosity: int -resources_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'resources')) -get_pip_script = os.path.join(resources_dir, 'get-pip.py') +resources_dir = Path(__file__).resolve().parent / 'resources' +get_pip_script = resources_dir / 'get-pip.py' diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py index fe535350..48c7c794 100644 --- a/cibuildwheel/windows.py +++ b/cibuildwheel/windows.py @@ -3,29 +3,32 @@ import shutil import subprocess import sys import tempfile -from glob import glob +from os import PathLike +from pathlib import Path +from typing import Dict, List, NamedTuple, Optional, Sequence, Union from zipfile import ZipFile - -from typing import Dict, List, Optional, NamedTuple +import toml from .environment import ParsedEnvironment -from .util import ( - BuildOptions, - BuildSelector, - download, - get_build_verbosity_extra_flags, - get_pip_script, - prepare_command, -) +from .util import (BuildOptions, BuildSelector, download, + get_build_verbosity_extra_flags, get_pip_script, + prepare_command) - -IS_RUNNING_ON_AZURE = os.path.exists('C:\\hostedtoolcache') +IS_RUNNING_ON_AZURE = Path('C:\\hostedtoolcache').exists() IS_RUNNING_ON_TRAVIS = os.environ.get('TRAVIS_OS_NAME') == 'windows' -def shell(args: List[str], env: Optional[Dict[str, str]] = None, cwd: Optional[str] = None) -> int: - print('+ ' + ' '.join(args)) - return subprocess.check_call(' '.join(args), env=env, cwd=cwd, shell=True) +def call(args: Sequence[Union[str, PathLike]], env: Optional[Dict[str, str]] = None, + cwd: Optional[str] = None) -> None: + print('+ ' + ' '.join(str(a) for a in args)) + # we use shell=True here, even though we don't need a shell due to a bug + # https://bugs.python.org/issue8557 + subprocess.check_call([str(a) for a in args], env=env, cwd=cwd, shell=True) + + +def shell(command: str, env: Optional[Dict[str, str]] = None, cwd: Optional[str] = None) -> None: + print(f'+ {command}') + subprocess.check_call(command, env=env, cwd=cwd, shell=True) def get_nuget_args(version: str, arch: str) -> List[str]: @@ -51,10 +54,10 @@ def get_python_configurations(build_selector: BuildSelector) -> List[PythonConfi PythonConfiguration(version='3.5.4', arch='64', identifier='cp35-win_amd64', url=None), PythonConfiguration(version='3.6.8', arch='32', identifier='cp36-win32', url=None), PythonConfiguration(version='3.6.8', arch='64', identifier='cp36-win_amd64', url=None), - PythonConfiguration(version='3.7.7', arch='32', identifier='cp37-win32', url=None), - PythonConfiguration(version='3.7.7', arch='64', identifier='cp37-win_amd64', url=None), - PythonConfiguration(version='3.8.3', arch='32', identifier='cp38-win32', url=None), - PythonConfiguration(version='3.8.3', arch='64', identifier='cp38-win_amd64', url=None), + PythonConfiguration(version='3.7.8', arch='32', identifier='cp37-win32', url=None), + PythonConfiguration(version='3.7.8', arch='64', identifier='cp37-win_amd64', url=None), + PythonConfiguration(version='3.8.4', arch='32', identifier='cp38-win32', url=None), + PythonConfiguration(version='3.8.4', arch='64', identifier='cp38-win_amd64', url=None), PythonConfiguration(version='3.9.0-b3', arch='32', identifier='cp39-win32', url=None), PythonConfiguration(version='3.9.0-b3', arch='64', identifier='cp39-win_amd64', url=None), # PyPy @@ -73,36 +76,38 @@ def get_python_configurations(build_selector: BuildSelector) -> List[PythonConfi return python_configurations -def extract_zip(zip_src: str, dest: str) -> None: +def extract_zip(zip_src: Path, dest: Path) -> None: with ZipFile(zip_src) as zip: zip.extractall(dest) -def install_cpython(version: str, arch: str, nuget: str) -> str: +def install_cpython(version: str, arch: str, nuget: Path) -> Path: nuget_args = get_nuget_args(version, arch) - installation_path = os.path.join(nuget_args[-1], nuget_args[0] + '.' + version, 'tools') - shell([nuget, 'install'] + nuget_args) + installation_path = Path(nuget_args[-1]) / (nuget_args[0] + '.' + version) / 'tools' + call([nuget, 'install', *nuget_args]) return installation_path -def install_pypy(version: str, arch: str, url: str) -> str: +def install_pypy(version: str, arch: str, url: str) -> Path: assert arch == '32' # Inside the PyPy zip file is a directory with the same name zip_filename = url.rsplit('/', 1)[-1] - installation_path = os.path.join('C:\\cibw', os.path.splitext(zip_filename)[0]) - if not os.path.exists(installation_path): - pypy_zip = os.path.join('C:\\cibw', zip_filename) + extension = ".zip" + assert zip_filename.endswith(extension) + installation_path = Path('C:\\cibw') / zip_filename[:-len(extension)] + if not installation_path.exists(): + pypy_zip = Path('C:\\cibw') / zip_filename download(url, pypy_zip) # Extract to the parent directory because the zip file still contains a directory - extract_zip(pypy_zip, os.path.dirname(installation_path)) + extract_zip(pypy_zip, installation_path.parent) pypy_exe = 'pypy3.exe' if version[0] == '3' else 'pypy.exe' - shell(['mklink', os.path.join(installation_path, 'python.exe'), os.path.join(installation_path, pypy_exe)]) + (installation_path / 'python.exe').symlink_to(installation_path / pypy_exe) return installation_path -def setup_python(python_configuration: PythonConfiguration, dependency_constraint_flags: List[str], environment: ParsedEnvironment) -> Dict[str, str]: - nuget = 'C:\\cibw\\nuget.exe' - if not os.path.exists(nuget): +def setup_python(python_configuration: PythonConfiguration, dependency_constraint_flags: Sequence[Union[str, PathLike]], environment: ParsedEnvironment) -> Dict[str, str]: + nuget = Path('C:\\cibw\\nuget.exe') + if not nuget.exists(): download('https://dist.nuget.org/win-x86-commandline/latest/nuget.exe', nuget) if python_configuration.identifier.startswith('cp'): @@ -113,58 +118,88 @@ def setup_python(python_configuration: PythonConfiguration, dependency_constrain else: raise ValueError("Unknown Python implementation") - assert os.path.exists(os.path.join(installation_path, 'python.exe')) + assert (installation_path / 'python.exe').exists() # set up PATH and environment variables for run_with_env env = os.environ.copy() env['PYTHON_VERSION'] = python_configuration.version env['PYTHON_ARCH'] = python_configuration.arch env['PATH'] = os.pathsep.join([ - installation_path, - os.path.join(installation_path, 'Scripts'), + str(installation_path), + str(installation_path / 'Scripts'), env['PATH'] ]) # update env with results from CIBW_ENVIRONMENT env = environment.as_dictionary(prev_environment=env) # for the logs - check we're running the right version of python - shell(['where', 'python'], env=env) - shell(['python', '--version'], env=env) - shell(['python', '-c', '"import struct; print(struct.calcsize(\'P\') * 8)"'], env=env) + call(['where', 'python'], env=env) + call(['python', '--version'], env=env) + call(['python', '-c', '"import struct; print(struct.calcsize(\'P\') * 8)"'], env=env) where_python = subprocess.check_output(['where', 'python'], env=env, universal_newlines=True).splitlines()[0].strip() - if where_python != os.path.join(installation_path, 'python.exe'): + if where_python != str(installation_path / 'python.exe'): 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) # make sure pip is installed - if not os.path.exists(os.path.join(installation_path, 'Scripts', 'pip.exe')): - shell(['python', get_pip_script] + dependency_constraint_flags, env=env, cwd="C:\\cibw") - assert os.path.exists(os.path.join(installation_path, 'Scripts', 'pip.exe')) + if not (installation_path / 'Scripts' / 'pip.exe').exists(): + call(['python', get_pip_script, *dependency_constraint_flags], env=env, cwd="C:\\cibw") + assert (installation_path / 'Scripts' / 'pip.exe').exists() where_pip = subprocess.check_output(['where', 'pip'], env=env, universal_newlines=True).splitlines()[0].strip() - if where_pip.strip() != os.path.join(installation_path, 'Scripts', 'pip.exe'): + if where_pip.strip() != str(installation_path / 'Scripts' / 'pip.exe'): 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) # prepare the Python environment - shell(['python', '-m', 'pip', 'install', '--upgrade', 'pip'] + dependency_constraint_flags, env=env) - shell(['pip', '--version'], env=env) - shell(['pip', 'install', '--upgrade', 'setuptools', 'wheel'] + dependency_constraint_flags, env=env) + call(['python', '-m', 'pip', 'install', '--upgrade', 'pip', *dependency_constraint_flags], env=env) + call(['pip', '--version'], env=env) + call(['pip', 'install', '--upgrade', 'setuptools', 'wheel', *dependency_constraint_flags], env=env) return env +def pep_518_cp35_workaround(package_dir: Path, env: Dict[str, str]) -> None: + """ + Python 3.5 PEP 518 hack (see https://github.com/pypa/pip/issues/8392#issuecomment-639563494) + Basically, nuget's Python is an embedded Python distribution, which is not supported by pip. + Before version 3.6, there was no way to disable the "embedded" behavior, including the ignoring + of environment variables, including the ones pip uses to setup PEP 518 builds. + + The fix here is as suggested in that issue; we manually setup the PEP 518 requirements. Since we + are in a fresh environment (except for pinned cibuildweel dependencies), the build is already + mostly "isolated". + """ + + pyproject_path = package_dir / 'pyproject.toml' + + if pyproject_path.exists(): + data = toml.load(pyproject_path) + requirements = ( + data['build-system'].get('requires', []) + if 'build-system' in data + else [] + ) + if requirements: + shell(['pip', 'install'] + requirements, env=env) + + def build(options: BuildOptions) -> None: - temp_dir = tempfile.mkdtemp(prefix='cibuildwheel') - built_wheel_dir = os.path.join(temp_dir, 'built_wheel') - repaired_wheel_dir = os.path.join(temp_dir, 'repaired_wheel') + temp_dir = Path(tempfile.mkdtemp(prefix='cibuildwheel')) + built_wheel_dir = temp_dir / 'built_wheel' + repaired_wheel_dir = temp_dir / 'repaired_wheel' # install nuget as best way to provide python - nuget = 'C:\\cibw\\nuget.exe' + nuget = Path('C:\\cibw\\nuget.exe') download('https://dist.nuget.org/win-x86-commandline/latest/nuget.exe', nuget) + if options.before_all: + env = options.environment.as_dictionary(prev_environment=os.environ) + before_all_prepared = prepare_command(options.before_all, project='.', package=options.package_dir) + shell(before_all_prepared, env=env) + python_configurations = get_python_configurations(options.build_selector) for config in python_configurations: - dependency_constraint_flags = [] + dependency_constraint_flags: Sequence[Union[str, PathLike]] = [] if options.dependency_constraints: dependency_constraint_flags = [ '-c', options.dependency_constraints.get_for_python_version(config.version) @@ -176,47 +211,59 @@ def build(options: BuildOptions) -> None: # run the before_build command if options.before_build: before_build_prepared = prepare_command(options.before_build, project='.', package=options.package_dir) - shell([before_build_prepared], env=env) + shell(before_build_prepared, env=env) + + # activate the PEP 518 patch if on Windows Python 3.5 + # (will only have an effect if PEP 517 builds are used): + if config.version.startswith('3.5'): + pep_518_cp35_workaround(options.package_dir, env) # build the wheel - if os.path.exists(built_wheel_dir): + if built_wheel_dir.exists(): shutil.rmtree(built_wheel_dir) - os.makedirs(built_wheel_dir) - # os.path.abspath is need. Without it pip wheel may try to fetch package from pypi.org + 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 - shell(['pip', 'wheel', os.path.abspath(options.package_dir), '-w', built_wheel_dir, '--no-deps'] + get_build_verbosity_extra_flags(options.build_verbosity), env=env) - built_wheel = glob(os.path.join(built_wheel_dir, '*.whl'))[0] + call([ + 'pip', 'wheel', + options.package_dir.resolve(), + '-w', 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 os.path.exists(repaired_wheel_dir): + if repaired_wheel_dir.exists(): shutil.rmtree(repaired_wheel_dir) - os.makedirs(repaired_wheel_dir) - if built_wheel.endswith('none-any.whl') or not options.repair_command: + 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 - shutil.move(built_wheel, repaired_wheel_dir) + shutil.move(str(built_wheel), repaired_wheel_dir) else: repair_command_prepared = prepare_command(options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir) - shell([repair_command_prepared], env=env) - repaired_wheel = glob(os.path.join(repaired_wheel_dir, '*.whl'))[0] + shell(repair_command_prepared, env=env) + 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. - shell(['pip', 'install', 'virtualenv'] + dependency_constraint_flags, env=env) - venv_dir = tempfile.mkdtemp() + 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 - shell(['python', '-m', 'virtualenv', '--no-download', venv_dir], env=env) + call(['python', '-m', 'virtualenv', '--no-download', venv_dir], env=env) virtualenv_env = env.copy() virtualenv_env['PATH'] = os.pathsep.join([ - os.path.join(venv_dir, 'Scripts'), + str(venv_dir / 'Scripts'), virtualenv_env['PATH'], ]) # check that we are using the Python from the virtual environment - shell(['which', 'python'], env=virtualenv_env) + call(['which', 'python'], env=virtualenv_env) if options.before_test: before_test_prepared = prepare_command( @@ -224,30 +271,27 @@ def build(options: BuildOptions) -> None: project='.', package=options.package_dir ) - shell([before_test_prepared], env=virtualenv_env) + shell(before_test_prepared, env=virtualenv_env) # install the wheel - shell(['pip', 'install', repaired_wheel + options.test_extras], env=virtualenv_env) + call(['pip', 'install', str(repaired_wheel) + options.test_extras], env=virtualenv_env) # test the wheel if options.test_requires: - shell(['pip', 'install'] + options.test_requires, env=virtualenv_env) + call(['pip', 'install'] + options.test_requires, env=virtualenv_env) # run the tests from c:\, 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=os.path.abspath('.'), - package=os.path.abspath(options.package_dir) + project=Path('.').resolve(), + package=options.package_dir.resolve() ) - shell([test_command_prepared], cwd='c:\\', env=virtualenv_env) + shell(test_command_prepared, cwd='c:\\', env=virtualenv_env) # clean up shutil.rmtree(venv_dir) # we're all done here; move it to output (remove if already exists) - dst = os.path.join(options.output_dir, os.path.basename(repaired_wheel)) - if os.path.isfile(dst): - os.remove(dst) - shutil.move(repaired_wheel, dst) + shutil.move(str(repaired_wheel), options.output_dir) diff --git a/docs/deliver-to-pypi.md b/docs/deliver-to-pypi.md index 35d36062..5c1fb2ee 100644 --- a/docs/deliver-to-pypi.md +++ b/docs/deliver-to-pypi.md @@ -33,4 +33,4 @@ Obviously, manual steps are for chumps, so we can automate this a little by usin If you don't need much control over the release of a package, you can set up cibuildwheel to deliver the wheels straight to PyPI. This doesn't require anycloud storage to work - you just need to bump the version and tag it. -[`examples/travis-deploy.yml`](https://github.com/joerick/cibuildwheel/blob/master/examples/travis-deploy.yml) and [`examples/github-deploy.yml`](https://github.com/joerick/cibuildwheel/blob/master/examples/travis-deploy.yml) are example configurations that automatocially upload wheels to PyPI. Also check out [this example repo](https://github.com/joerick/cibuildwheel-autopypi-example) for more detailed instructions on how to set this up. +[`examples/travis-ci-deploy.yml`](https://github.com/joerick/cibuildwheel/blob/master/examples/travis-ci-deploy.yml) and [`examples/github-deploy.yml`](https://github.com/joerick/cibuildwheel/blob/master/examples/github-deploy.yml) are example configurations that automatically upload wheels to PyPI. Also check out [this example repo](https://github.com/joerick/cibuildwheel-autopypi-example) for more detailed instructions on how to set this up. diff --git a/docs/extra.js b/docs/extra.js index d32c3cc0..1965a84e 100644 --- a/docs/extra.js +++ b/docs/extra.js @@ -2,4 +2,3 @@ $('.rst-content pre') .prev('blockquote') .addClass('code-block-filename'); - diff --git a/docs/mkdocs_include_markdown_plugin/mkdocs_include_markdown_plugin/plugin.py b/docs/mkdocs_include_markdown_plugin/mkdocs_include_markdown_plugin/plugin.py index b6b7a723..f12ea469 100644 --- a/docs/mkdocs_include_markdown_plugin/mkdocs_include_markdown_plugin/plugin.py +++ b/docs/mkdocs_include_markdown_plugin/mkdocs_include_markdown_plugin/plugin.py @@ -1,7 +1,6 @@ import cgi -import io -import os import re +from pathlib import Path import mkdocs @@ -41,13 +40,12 @@ class ImportMarkdownPlugin(mkdocs.plugins.BasePlugin): def found_include_tag(match): filename = match.group('filename') - file_path_abs = os.path.join(os.path.dirname(page_src_path), filename) + file_path_abs = Path(page_src_path).parent / filename - if not os.path.exists(file_path_abs): + if not file_path_abs.exists(): raise ValueError('file not found', filename) - with io.open(file_path_abs, encoding='utf8') as f: - text_to_include = f.read() + text_to_include = file_path_abs.read_text(encoding='utf8') # Allow good practice of having a final newline in the file if text_to_include.endswith('\n'): @@ -60,13 +58,12 @@ class ImportMarkdownPlugin(mkdocs.plugins.BasePlugin): start = match.group('start') end = match.group('end') - file_path_abs = os.path.join(os.path.dirname(page_src_path), filename) + file_path_abs = Path(page_src_path).parent / filename - if not os.path.exists(file_path_abs): + if not file_path_abs.exists(): raise ValueError('file not found', filename) - with io.open(file_path_abs, encoding='utf8') as f: - text_to_include = f.read() + text_to_include = file_path_abs.read_text(encoding='utf8') if start: _, _, text_to_include = text_to_include.partition(start) diff --git a/docs/options.md b/docs/options.md index 18d5417b..856f0bad 100644 --- a/docs/options.md +++ b/docs/options.md @@ -190,6 +190,28 @@ CIBW_ENVIRONMENT: "BUILD_TIME=$(date) SAMPLE_TEXT=\"sample text\"" !!! note `cibuildwheel` always defines the environment variable `CIBUILDWHEEL=1`. This can be useful for [building wheels with optional extensions](faq.md#building-packages-with-optional-c-extensions). +### `CIBW_BEFORE_ALL` {: #before-all} +> Execute a shell command on the build system before any wheels are built. + +Shell command to prepare a common part of the project (e.g. build or install libraries which does not depend on the specific version of Python). + +This option is very useful for the Linux build, where builds take place in isolated Docker containers managed by cibuildwheel. This command will run inside the container before the wheel builds start. Note, if you're building both x86_64 and i686 wheels (the default), your build uses two different Docker images. In that case, this command will execute twice - once per build container. + +The placeholder `{package}` can be used here; it will be replaced by the path to the package being built by `cibuildwheel`. + +On Windows and macOS, the version of Python available inside `CIBW_BEFORE_ALL` is whatever is available on the host machine. On Linux, a modern Python version is available on PATH. + +Platform-specific variants also available:
+ `CIBW_BEFORE_ALL_MACOS` | `CIBW_BEFORE_ALL_WINDOWS` | `CIBW_BEFORE_ALL_LINUX` + +#### Examples +```yaml +# build third party library +CIBW_BEFORE_ALL: make -C third_party_lib + +# install system library +CIBW_BEFORE_ALL_LINUX: yum install -y libffi-dev +``` ### `CIBW_BEFORE_BUILD` {: #before-build} > Execute a shell command preparing each wheel's build @@ -603,7 +625,7 @@ optional arguments: var url = 'https://cibuildwheel.readthedocs.io/en/stable/options/#'+option.id; var namesMarkdown = $.map(optionNames, function(n) { return '[`'+n+'`]('+url+') ' - }).join(' ') + }).join('
') markdown += '| '+namesMarkdown+' ' markdown += '| '+option.description.trim()+' ' diff --git a/examples/appveyor-minimal.yml b/examples/appveyor-minimal.yml index 849fe274..0a632024 100644 --- a/examples/appveyor-minimal.yml +++ b/examples/appveyor-minimal.yml @@ -12,7 +12,7 @@ stack: python 3.7 init: - cmd: set PATH=C:\Python37;C:\Python37\Scripts;%PATH% -install: python -m pip install cibuildwheel==1.4.2 +install: python -m pip install cibuildwheel==1.5.5 build_script: python -m cibuildwheel --output-dir wheelhouse diff --git a/examples/azure-pipelines-minimal.yml b/examples/azure-pipelines-minimal.yml index 3266e888..055eb36a 100644 --- a/examples/azure-pipelines-minimal.yml +++ b/examples/azure-pipelines-minimal.yml @@ -5,7 +5,7 @@ jobs: - task: UsePythonVersion@0 - bash: | python3 -m pip install --upgrade pip - pip3 install cibuildwheel==1.4.2 + pip3 install cibuildwheel==1.5.5 cibuildwheel --output-dir wheelhouse . - task: PublishBuildArtifacts@1 inputs: {pathtoPublish: 'wheelhouse'} @@ -16,7 +16,7 @@ jobs: - task: UsePythonVersion@0 - bash: | python3 -m pip install --upgrade pip - pip3 install cibuildwheel==1.4.2 + pip3 install cibuildwheel==1.5.5 cibuildwheel --output-dir wheelhouse . - task: PublishBuildArtifacts@1 inputs: {pathtoPublish: 'wheelhouse'} @@ -29,7 +29,7 @@ jobs: displayName: Install Visual C++ for Python 2.7 - bash: | python -m pip install --upgrade pip - pip install cibuildwheel==1.4.2 + pip install cibuildwheel==1.5.5 cibuildwheel --output-dir wheelhouse . - task: PublishBuildArtifacts@1 inputs: {pathtoPublish: 'wheelhouse'} diff --git a/examples/circleci-minimal.yml b/examples/circleci-minimal.yml index 747b3f8e..a7e71c5a 100644 --- a/examples/circleci-minimal.yml +++ b/examples/circleci-minimal.yml @@ -11,7 +11,7 @@ jobs: - run: name: Build the Linux wheels. command: | - pip3 install --user cibuildwheel==1.4.2 + pip3 install --user cibuildwheel==1.5.5 cibuildwheel --output-dir wheelhouse - store_artifacts: path: wheelhouse/ @@ -25,7 +25,7 @@ jobs: - run: name: Build the OS X wheels. command: | - pip3 install --user cibuildwheel==1.4.2 + pip3 install --user cibuildwheel==1.5.5 cibuildwheel --output-dir wheelhouse - store_artifacts: path: wheelhouse/ diff --git a/examples/github-deploy.yml b/examples/github-deploy.yml index 746c5847..d0aef52f 100644 --- a/examples/github-deploy.yml +++ b/examples/github-deploy.yml @@ -28,7 +28,7 @@ jobs: - name: Install cibuildwheel run: | - python -m pip install cibuildwheel==1.4.2 + python -m pip install cibuildwheel==1.5.5 - name: Install Visual C++ for Python 2.7 if: runner.os == 'Windows' diff --git a/examples/github-minimal.yml b/examples/github-minimal.yml index 8501cb84..f88b262f 100644 --- a/examples/github-minimal.yml +++ b/examples/github-minimal.yml @@ -20,7 +20,7 @@ jobs: - name: Install cibuildwheel run: | - python -m pip install cibuildwheel==1.4.2 + python -m pip install cibuildwheel==1.5.5 - name: Install Visual C++ for Python 2.7 if: runner.os == 'Windows' diff --git a/examples/travis-ci-deploy.yml b/examples/travis-ci-deploy.yml index 5e82149f..08dc997f 100644 --- a/examples/travis-ci-deploy.yml +++ b/examples/travis-ci-deploy.yml @@ -25,7 +25,7 @@ env: # Note: TWINE_PASSWORD is set to a PyPI API token in Travis settings install: - - python3 -m pip install cibuildwheel==1.4.2 + - python3 -m pip install cibuildwheel==1.5.5 script: # build the wheels, put them into './wheelhouse' diff --git a/examples/travis-ci-minimal.yml b/examples/travis-ci-minimal.yml index b03a0da5..05fd5240 100644 --- a/examples/travis-ci-minimal.yml +++ b/examples/travis-ci-minimal.yml @@ -26,7 +26,7 @@ jobs: - ln -s /c/Python38/python.exe /c/Python38/python3.exe install: - - python3 -m pip install cibuildwheel==1.4.2 + - python3 -m pip install cibuildwheel==1.5.5 script: # build the wheels, put them into './wheelhouse' diff --git a/examples/travis-ci-test-and-deploy.yml b/examples/travis-ci-test-and-deploy.yml index b9d920f6..6b9abd51 100644 --- a/examples/travis-ci-test-and-deploy.yml +++ b/examples/travis-ci-test-and-deploy.yml @@ -55,7 +55,7 @@ jobs: - stage: deploy name: Build and deploy Linux wheels services: docker - install: python3 -m pip install cibuildwheel==1.4.2 + install: python3 -m pip install cibuildwheel==1.5.5 script: python3 -m cibuildwheel --output-dir wheelhouse after_success: | python3 -m pip install twine @@ -65,7 +65,7 @@ jobs: name: Build and deploy macOS wheels os: osx language: shell - install: python3 -m pip install cibuildwheel==1.4.2 + install: python3 -m pip install cibuildwheel==1.5.5 script: python3 -m cibuildwheel --output-dir wheelhouse after_success: | python3 -m pip install twine @@ -75,7 +75,7 @@ jobs: name: Build and deploy Windows wheels os: windows language: shell - install: python3 -m pip install cibuildwheel==1.4.2 + install: python3 -m pip install cibuildwheel==1.5.5 script: python3 -m cibuildwheel --output-dir wheelhouse after_success: | python3 -m pip install twine diff --git a/requirements-dev.txt b/requirements-dev.txt index 44043506..e6129684 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -6,3 +6,5 @@ pymdown-extensions pip-tools requests click +mypy +typing-extensions diff --git a/setup.cfg b/setup.cfg index d54a42bb..e98764a2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [flake8] -ignore = E501,W503,E741 +ignore = E501,W503,E741,E226 application-import-names = cibuildwheel exclude = cibuildwheel/resources/, diff --git a/setup.py b/setup.py index 3e4c90cb..513c085e 100644 --- a/setup.py +++ b/setup.py @@ -1,20 +1,18 @@ # -*- coding: utf-8 -*- -import io -import os +from pathlib import Path try: from setuptools import setup except ImportError: from distutils.core import setup -this_directory = os.path.dirname(__file__) -with io.open(os.path.join(this_directory, 'README.md'), encoding='utf-8') as f: - long_description = f.read() +this_directory = Path(__file__).parent +long_description = (this_directory / 'README.md').read_text(encoding='utf-8') setup( name='cibuildwheel', - version='1.4.2', - install_requires=['bashlex!=0.13'], + version='1.5.5', + install_requires=['bashlex!=0.13', 'toml'], description="Build Python wheels on CI with minimal configuration.", long_description=long_description, long_description_content_type='text/markdown', diff --git a/test/test_before_all.py b/test/test_before_all.py new file mode 100644 index 00000000..73d36ff6 --- /dev/null +++ b/test/test_before_all.py @@ -0,0 +1,63 @@ +import pytest +import subprocess +import textwrap + +from . import utils +from . import test_projects + +project_with_before_build_asserts = test_projects.new_c_project( + setup_py_add=textwrap.dedent(r''' + # assert that the Python version as written to text_info.txt in the CIBW_BEFORE_ALL step + # is the same one as is currently running. + with open("text_info.txt") as f: + stored_text = f.read() + + print("## stored text: " + stored_text) + assert stored_text == "sample text 123" + ''') +) + + +def test(tmp_path): + project_dir = tmp_path / 'project' + project_with_before_build_asserts.generate(project_dir) + + with (project_dir / 'text_info.txt').open(mode='w') as ff: + print("dummy text", file=ff) + + # build the wheels + actual_wheels = utils.cibuildwheel_run(project_dir, add_env={ + # write python version information to a temporary file, this is + # checked in setup.py + 'CIBW_BEFORE_ALL': '''python -c "import os;open('{project}/text_info.txt', 'w').write('sample text '+os.environ.get('TEST_VAL', ''))"''', + 'CIBW_ENVIRONMENT': "TEST_VAL='123'" + }) + + # also check that we got the right wheels + (project_dir / 'text_info.txt').unlink() + expected_wheels = utils.expected_wheels('spam', '0.1.0') + assert set(actual_wheels) == set(expected_wheels) + + +def test_failing_command(tmp_path): + project_dir = tmp_path / 'project' + test_projects.new_c_project().generate(project_dir) + + with pytest.raises(subprocess.CalledProcessError): + utils.cibuildwheel_run(project_dir, add_env={ + 'CIBW_BEFORE_ALL': 'false', + 'CIBW_BEFORE_ALL_WINDOWS': 'exit /b 1', + }) + + +def test_cwd(tmp_path): + project_dir = tmp_path / 'project' + test_projects.new_c_project().generate(project_dir) + + actual_wheels = utils.cibuildwheel_run(project_dir, add_env={ + 'CIBW_BEFORE_ALL': f'''python -c "import os; assert os.getcwd() == {str(project_dir)!r}"''', + 'CIBW_BEFORE_ALL_LINUX': '''python -c "import os; assert os.getcwd() == '/project'"''', + }) + + expected_wheels = utils.expected_wheels('spam', '0.1.0') + assert set(actual_wheels) == set(expected_wheels) diff --git a/test/test_before_build.py b/test/test_before_build.py index 11c752f0..cb92280b 100644 --- a/test/test_before_build.py +++ b/test/test_before_build.py @@ -1,3 +1,5 @@ +import pytest +import subprocess import textwrap from . import utils @@ -32,14 +34,41 @@ def test(tmp_path): project_dir = tmp_path / 'project' project_with_before_build_asserts.generate(project_dir) + before_build = ('''python -c "import sys; open('{output_dir}pythonversion.txt', 'w').write(sys.version)" && ''' + '''python -c "import sys; open('{output_dir}pythonexecutable.txt', 'w').write(sys.executable)"''') + # build the wheels actual_wheels = utils.cibuildwheel_run(project_dir, add_env={ # write python version information to a temporary file, this is # checked in setup.py - 'CIBW_BEFORE_BUILD': '''python -c "import sys; open('/tmp/pythonversion.txt', 'w').write(sys.version)" && python -c "import sys; open('/tmp/pythonexecutable.txt', 'w').write(sys.executable)"''', - 'CIBW_BEFORE_BUILD_WINDOWS': '''python -c "import sys; open('c:\\pythonversion.txt', 'w').write(sys.version)" && python -c "import sys; open('c:\\pythonexecutable.txt', 'w').write(sys.executable)"''', + 'CIBW_BEFORE_BUILD': before_build.format(output_dir='/tmp/'), + 'CIBW_BEFORE_BUILD_WINDOWS': before_build.format(output_dir=r'c:\\'), }) # also check that we got the right wheels expected_wheels = utils.expected_wheels('spam', '0.1.0') assert set(actual_wheels) == set(expected_wheels) + + +def test_failing_command(tmp_path): + project_dir = tmp_path / 'project' + test_projects.new_c_project().generate(project_dir) + + with pytest.raises(subprocess.CalledProcessError): + utils.cibuildwheel_run(project_dir, add_env={ + 'CIBW_BEFORE_BUILD': 'false', + 'CIBW_BEFORE_BUILD_WINDOWS': 'exit /b 1', + }) + + +def test_cwd(tmp_path): + project_dir = tmp_path / 'project' + test_projects.new_c_project().generate(project_dir) + + actual_wheels = utils.cibuildwheel_run(project_dir, add_env={ + 'CIBW_BEFORE_BUILD': f'''python -c "import os; assert os.getcwd() == {str(project_dir)!r}"''', + 'CIBW_BEFORE_BUILD_LINUX': '''python -c "import os; assert os.getcwd() == '/project'"''', + }) + + expected_wheels = utils.expected_wheels('spam', '0.1.0') + assert set(actual_wheels) == set(expected_wheels) diff --git a/test/test_dependency_versions.py b/test/test_dependency_versions.py index 611350f1..2f79bd2a 100644 --- a/test/test_dependency_versions.py +++ b/test/test_dependency_versions.py @@ -1,4 +1,3 @@ -import os import re import pytest import textwrap @@ -39,11 +38,9 @@ VERSION_REGEX = r'([\w-]+)==([^\s]+)' def get_versions_from_constraint_file(constraint_file): - with open(constraint_file, encoding='utf8') as f: - constraint_file_text = f.read() + constraint_file_text = constraint_file.read_text(encoding='utf8') versions = {} - for package, version in re.findall(VERSION_REGEX, constraint_file_text): versions[package] = version @@ -73,7 +70,7 @@ def test_pinned_versions(tmp_path, python_version): constraint_filename = 'constraints.txt' build_pattern = '[cp]p38-*' - constraint_file = os.path.join(cibuildwheel.util.resources_dir, constraint_filename) + constraint_file = cibuildwheel.util.resources_dir / constraint_filename constraint_versions = get_versions_from_constraint_file(constraint_file) for package in ['pip', 'setuptools', 'wheel', 'virtualenv']: diff --git a/test/test_environment.py b/test/test_environment.py index 470a151f..7de1c0bf 100644 --- a/test/test_environment.py +++ b/test/test_environment.py @@ -13,12 +13,15 @@ project_with_environment_asserts = test_projects.new_c_project( # explode if environment isn't correct, as set in CIBW_ENVIRONMENT CIBW_TEST_VAR = os.environ.get("CIBW_TEST_VAR") CIBW_TEST_VAR_2 = os.environ.get("CIBW_TEST_VAR_2") + CIBW_TEST_VAR_3 = os.environ.get("CIBW_TEST_VAR_3") PATH = os.environ.get("PATH") if CIBW_TEST_VAR != "a b c": raise Exception('CIBW_TEST_VAR should equal "a b c". It was "%s"' % CIBW_TEST_VAR) if CIBW_TEST_VAR_2 != "1": raise Exception('CIBW_TEST_VAR_2 should equal "1". It was "%s"' % CIBW_TEST_VAR_2) + if CIBW_TEST_VAR_3 != "test string 3": + raise Exception('CIBW_TEST_VAR_3 should equal "test string 3". It was "%s"' % CIBW_TEST_VAR_3) if "/opt/cibw_test_path" not in PATH: raise Exception('PATH should contain "/opt/cibw_test_path". It was "%s"' % PATH) if "$PATH" in PATH: @@ -44,7 +47,7 @@ def test(tmp_path): assert set(actual_wheels) == set(expected_wheels) -def test_overridden_path(tmp_path): +def test_overridden_path(tmp_path, capfd): project_dir = tmp_path / 'project' output_dir = tmp_path / 'output' @@ -54,8 +57,21 @@ def test_overridden_path(tmp_path): # mess up PATH, somehow with pytest.raises(subprocess.CalledProcessError): - utils.cibuildwheel_run(project_dir, output_dir=output_dir, add_env={ - 'CIBW_ENVIRONMENT': '''SOMETHING="$(mkdir new_path && touch new_path/python)" PATH="$(realpath new_path):$PATH"''', - 'CIBW_ENVIRONMENT_WINDOWS': '''SOMETHING="$(mkdir new_path && type nul > new_path/python.exe)" PATH="$CD\\new_path;$PATH"''', - }) + if utils.platform == 'linux': + utils.cibuildwheel_run(project_dir, output_dir=output_dir, add_env={ + 'CIBW_BEFORE_ALL': 'mkdir new_path && touch new_path/python && chmod +x new_path/python', + 'CIBW_ENVIRONMENT': '''PATH="$(pwd)/new_path:$PATH"''', + }) + else: + new_path = tmp_path / 'another_bin' + new_path.mkdir() + (new_path / 'python').touch(mode=0o777) + + utils.cibuildwheel_run(project_dir, output_dir=output_dir, add_env={ + 'NEW_PATH': str(new_path), + 'CIBW_ENVIRONMENT': f'''PATH="$NEW_PATH{os.pathsep}$PATH"''', + }) + assert len(os.listdir(output_dir)) == 0 + captured = capfd.readouterr() + assert "python available on PATH doesn't match our installed instance" in captured.err diff --git a/test/test_pep518.py b/test/test_pep518.py new file mode 100644 index 00000000..9257f7db --- /dev/null +++ b/test/test_pep518.py @@ -0,0 +1,49 @@ +import textwrap +from . import test_projects +from . import utils + +basic_project = test_projects.new_c_project( + setup_py_add=textwrap.dedent( + """ + # Will fail if PEP 518 does work + import sys + import requests + if sys.version_info < (3, 6, 0): + assert requests.__version__ == "2.22.0", "Requests found but wrong version ({0})".format(requests.__version__) + else: + assert requests.__version__ == "2.23.0", "Requests found but wrong version ({0})".format(requests.__version__) + + # Just making sure environment is still set + import os + if os.environ.get("CIBUILDWHEEL", "0") != "1": + raise Exception("CIBUILDWHEEL environment variable is not set to 1") + """ + ) +) + +basic_project.files[ + "pyproject.toml" +] = """ +[build-system] +requires = [ + "setuptools >= 42", + "wheel", + "requests==2.22.0; python_version<'3.6'", + "requests==2.23.0; python_version>='3.6'" +] + +build-backend = "setuptools.build_meta" +""" + + +def test_pep518(tmp_path): + + project_dir = tmp_path / "project" + basic_project.generate(project_dir) + + # build the wheels + actual_wheels = utils.cibuildwheel_run(project_dir) + + # check that the expected wheels are produced + expected_wheels = utils.expected_wheels("spam", "0.1.0") + assert set(actual_wheels) == set(expected_wheels) diff --git a/test/test_projects/__main__.py b/test/test_projects/__main__.py index af5b14dc..06fe696f 100644 --- a/test/test_projects/__main__.py +++ b/test/test_projects/__main__.py @@ -1,8 +1,9 @@ -from argparse import ArgumentParser import importlib -import tempfile -import sys import subprocess +import sys +import tempfile +from argparse import ArgumentParser +from pathlib import Path def main(): @@ -19,7 +20,7 @@ def main(): project = getattr(importlib.import_module(module), name) - project_dir = tempfile.mkdtemp() + project_dir = Path(tempfile.mkdtemp()) project.generate(project_dir) print('Project generated at', project_dir) diff --git a/test/test_projects/base.py b/test/test_projects/base.py index b38310cc..a5710fdf 100644 --- a/test/test_projects/base.py +++ b/test/test_projects/base.py @@ -1,5 +1,7 @@ -import os +from pathlib import Path + import jinja2 + from typing import Union, Dict, Any @@ -23,12 +25,12 @@ class TestProject: self.files = {} self.template_context = {} - def generate(self, path: str): + def generate(self, path: Path): for filename, content in self.files.items(): - file_path = os.path.join(path, filename) - os.makedirs(os.path.dirname(file_path), exist_ok=True) + file_path = path / filename + file_path.parent.mkdir(parents=True, exist_ok=True) - with open(file_path, 'w', encoding='utf8') as f: + with file_path.open('w', encoding='utf8') as f: if isinstance(content, jinja2.Template): content = content.render(self.template_context) diff --git a/test/test_subdir_package.py b/test/test_subdir_package.py index 98b1b8cd..14a7a58b 100644 --- a/test/test_subdir_package.py +++ b/test/test_subdir_package.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path import jinja2 @@ -35,7 +35,7 @@ def test(capfd, tmp_path): project_dir = tmp_path / 'project' subdir_package_project.generate(project_dir) - package_dir = os.path.join('src', 'spam') + package_dir = Path('src', 'spam') # build the wheels actual_wheels = utils.cibuildwheel_run(project_dir, package_dir=package_dir, add_env={ 'CIBW_BEFORE_BUILD': 'python {project}/bin/before_build.py', diff --git a/test/utils.py b/test/utils.py index ce146e8b..05f2a796 100644 --- a/test/utils.py +++ b/test/utils.py @@ -10,9 +10,10 @@ import shutil import subprocess import sys from contextlib import contextmanager +from pathlib import Path from tempfile import mkdtemp -IS_WINDOWS_RUNNING_ON_AZURE = os.path.exists('C:\\hostedtoolcache') +IS_WINDOWS_RUNNING_ON_AZURE = Path('C:\\hostedtoolcache').exists() IS_WINDOWS_RUNNING_ON_TRAVIS = os.environ.get('TRAVIS_OS_NAME') == 'windows' @@ -66,7 +67,7 @@ def cibuildwheel_run(project_path, package_dir='.', env=None, add_env=None, outp with TemporaryDirectoryIfNone(output_dir) as _output_dir: subprocess.check_call( - [sys.executable, '-m', 'cibuildwheel', '--output-dir', str(_output_dir), package_dir], + [sys.executable, '-m', 'cibuildwheel', '--output-dir', str(_output_dir), str(package_dir)], env=env, cwd=project_path, ) diff --git a/unit_test/conftest.py b/unit_test/conftest.py new file mode 100644 index 00000000..a54658a9 --- /dev/null +++ b/unit_test/conftest.py @@ -0,0 +1,21 @@ +import pytest + + +def pytest_addoption(parser): + parser.addoption( + "--run-docker", action="store_true", default=False, help="run docker tests" + ) + + +def pytest_configure(config): + config.addinivalue_line("markers", "docker: mark test requiring docker to run") + + +def pytest_collection_modifyitems(config, items): + if config.getoption("--run-docker"): + # --run-docker given in cli: do not skip docker tests + return + skip_docker = pytest.mark.skip(reason="need --run-docker option to run") + for item in items: + if "docker" in item.keywords: + item.add_marker(skip_docker) diff --git a/unit_test/dependency_constraints_test.py b/unit_test/dependency_constraints_test.py index b3fb0cb3..5347011c 100644 --- a/unit_test/dependency_constraints_test.py +++ b/unit_test/dependency_constraints_test.py @@ -1,30 +1,16 @@ from cibuildwheel.util import DependencyConstraints -import os + +from pathlib import Path def test_defaults(): dependency_constraints = DependencyConstraints.with_defaults() - project_root = os.path.dirname(os.path.dirname(__file__)) - resources_dir = os.path.join(project_root, 'cibuildwheel', 'resources') + project_root = Path(__file__).parents[1] + resources_dir = project_root / 'cibuildwheel' / 'resources' - assert os.path.samefile( - dependency_constraints.base_file_path, - os.path.join(resources_dir, 'constraints.txt') - ) - assert os.path.samefile( - dependency_constraints.get_for_python_version('3.8'), - os.path.join(resources_dir, 'constraints.txt') - ) - assert os.path.samefile( - dependency_constraints.get_for_python_version('3.6'), - os.path.join(resources_dir, 'constraints-python36.txt') - ) - assert os.path.samefile( - dependency_constraints.get_for_python_version('3.5'), - os.path.join(resources_dir, 'constraints-python35.txt') - ) - assert os.path.samefile( - dependency_constraints.get_for_python_version('2.7'), - os.path.join(resources_dir, 'constraints-python27.txt') - ) + assert dependency_constraints.base_file_path.samefile(resources_dir / 'constraints.txt') + assert dependency_constraints.get_for_python_version('3.8').samefile(resources_dir / 'constraints.txt') + assert dependency_constraints.get_for_python_version('3.6').samefile(resources_dir / 'constraints-python36.txt') + assert dependency_constraints.get_for_python_version('3.5').samefile(resources_dir / 'constraints-python35.txt') + assert dependency_constraints.get_for_python_version('2.7').samefile(resources_dir / 'constraints-python27.txt') diff --git a/unit_test/docker_container_test.py b/unit_test/docker_container_test.py new file mode 100644 index 00000000..22771ed2 --- /dev/null +++ b/unit_test/docker_container_test.py @@ -0,0 +1,162 @@ +import platform +import random +import shutil +import subprocess +import textwrap +from pathlib import Path, PurePath + +import pytest + +from cibuildwheel.docker_container import DockerContainer +from cibuildwheel.environment import EnvironmentAssignment + +# for these tests we use manylinux2014 images, because they're available on +# multi architectures and include python3.8 +pm = platform.machine() +if pm == "x86_64": + DEFAULT_IMAGE = 'quay.io/pypa/manylinux2014_x86_64:2020-05-17-2f8ac3b' +elif pm == "aarch64": + DEFAULT_IMAGE = 'quay.io/pypa/manylinux2014_aarch64:2020-05-17-2f8ac3b' +elif pm == "ppc64le": + DEFAULT_IMAGE = 'quay.io/pypa/manylinux2014_ppc64le:2020-05-17-2f8ac3b' +elif pm == "s390x": + DEFAULT_IMAGE = 'quay.io/pypa/manylinux2014_s390x:2020-05-17-2f8ac3b' + + +@pytest.mark.docker +def test_simple(): + with DockerContainer(DEFAULT_IMAGE) as container: + assert container.call(['echo', 'hello'], capture_output=True) == 'hello\n' + + +@pytest.mark.docker +def test_no_lf(): + with DockerContainer(DEFAULT_IMAGE) as container: + assert container.call(['printf', 'hello'], capture_output=True) == 'hello' + + +@pytest.mark.docker +def test_environment(): + with DockerContainer(DEFAULT_IMAGE) as container: + assert container.call(['sh', '-c', 'echo $TEST_VAR'], env={'TEST_VAR': '1'}, capture_output=True) == '1\n' + + +@pytest.mark.docker +def test_cwd(): + with DockerContainer(DEFAULT_IMAGE, cwd='/cibuildwheel/working_directory') as container: + assert container.call(['pwd'], capture_output=True) == '/cibuildwheel/working_directory\n' + assert container.call(['pwd'], capture_output=True, cwd='/opt') == '/opt\n' + + +@pytest.mark.docker +def test_container_removed(): + with DockerContainer(DEFAULT_IMAGE) as container: + docker_containers_listing = subprocess.run('docker container ls', shell=True, check=True, stdout=subprocess.PIPE, universal_newlines=True).stdout + assert container.name in docker_containers_listing + old_container_name = container.name + + docker_containers_listing = subprocess.run('docker container ls', shell=True, check=True, stdout=subprocess.PIPE, universal_newlines=True).stdout + assert old_container_name not in docker_containers_listing + + +@pytest.mark.docker +def test_large_environment(): + # max environment variable size is 128kB + long_env_var_length = 127*1024 + large_environment = { + 'a': '0'*long_env_var_length, + 'b': '0'*long_env_var_length, + 'c': '0'*long_env_var_length, + 'd': '0'*long_env_var_length, + } + + with DockerContainer(DEFAULT_IMAGE) as container: + # check the length of d + assert container.call(['sh', '-c', 'echo ${#d}'], env=large_environment, capture_output=True) == f'{long_env_var_length}\n' + + +@pytest.mark.docker +def test_binary_output(): + with DockerContainer(DEFAULT_IMAGE) as container: + # note: the below embedded snippets are in python2 + + # check that we can pass though arbitrary binary data without erroring + container.call(['/usr/bin/python2', '-c', textwrap.dedent(''' + import sys + sys.stdout.write(''.join(chr(n) for n in range(0, 256))) + ''')]) + + # check that we can capture arbitrary binary data + output = container.call(['/usr/bin/python2', '-c', textwrap.dedent(''' + import sys + sys.stdout.write(''.join(chr(n % 256) for n in range(0, 512))) + ''')], capture_output=True) + + data = bytes(output, encoding='utf8', errors='surrogateescape') + + for i in range(0, 512): + assert data[i] == i % 256 + + # check that environment variables can carry binary data, except null characters + # (https://www.gnu.org/software/libc/manual/html_node/Environment-Variables.html) + binary_data = bytes(n for n in range(1, 256)) + binary_data_string = str(binary_data, encoding='utf8', errors='surrogateescape') + output = container.call( + ['python2', '-c', 'import os, sys; sys.stdout.write(os.environ["TEST_VAR"])'], + env={'TEST_VAR': binary_data_string}, + capture_output=True, + ) + assert output == binary_data_string + + +@pytest.mark.docker +def test_file_operations(tmp_path: Path): + with DockerContainer(DEFAULT_IMAGE) as container: + # test copying a file in + test_binary_data = bytes(random.randrange(256) for _ in range(1000)) + original_test_file = tmp_path / 'test.dat' + original_test_file.write_bytes(test_binary_data) + + dst_file = PurePath('/tmp/test.dat') + + container.copy_into(original_test_file, dst_file) + + output = container.call(['cat', dst_file], capture_output=True) + assert test_binary_data == bytes(output, encoding='utf8', errors='surrogateescape') + + +@pytest.mark.docker +def test_dir_operations(tmp_path: Path): + with DockerContainer(DEFAULT_IMAGE) as container: + test_binary_data = bytes(random.randrange(256) for _ in range(1000)) + original_test_file = tmp_path / 'test.dat' + original_test_file.write_bytes(test_binary_data) + + # test copying a dir in + test_dir = tmp_path / 'test_dir' + test_dir.mkdir() + test_file = test_dir / 'test.dat' + shutil.copyfile(original_test_file, test_file) + + dst_dir = PurePath('/tmp/test_dir') + dst_file = dst_dir / 'test.dat' + container.copy_into(test_dir, dst_dir) + + output = container.call(['cat', dst_file], capture_output=True) + assert test_binary_data == bytes(output, encoding='utf8', errors='surrogateescape') + + # test glob + assert container.glob(dst_dir, '*.dat') == [dst_file] + + # test copy dir out + new_test_dir = tmp_path / 'test_dir_new' + container.copy_out(dst_dir, new_test_dir) + + assert test_binary_data == (new_test_dir / 'test.dat').read_bytes() + + +@pytest.mark.docker +def test_environment_executor(): + with DockerContainer(DEFAULT_IMAGE) as container: + assignment = EnvironmentAssignment("TEST=$(echo 42)") + assert assignment.evaluated_value({}, container.environment_executor) == "42" diff --git a/unit_test/environment_test.py b/unit_test/environment_test.py index b8b961a8..ab690a84 100644 --- a/unit_test/environment_test.py +++ b/unit_test/environment_test.py @@ -40,7 +40,7 @@ def test_inheritance(): def test_shell_eval(): - environment_recipe = parse_environment('VAR="$(echo "a test" string)"') + environment_recipe = parse_environment('VAR="$(echo "a test" string)"') env_copy = os.environ.copy() env_copy.pop('VAR', None) @@ -50,8 +50,8 @@ def test_shell_eval(): ) environment_cmds = environment_recipe.as_shell_commands() - assert environment_dict['VAR'] == 'a test string' - assert environment_cmds == ['export VAR="$(echo "a test" string)"'] + assert environment_dict['VAR'] == 'a test string' + assert environment_cmds == ['export VAR="$(echo "a test" string)"'] def test_shell_eval_and_env(): @@ -94,3 +94,32 @@ def test_no_vars_pass_through(): environment_dict = environment_recipe.as_dictionary(prev_environment={'CIBUILDWHEEL': 'awesome'}) assert environment_dict == {'CIBUILDWHEEL': 'awesome'} + + +def test_operators_inside_eval(): + environment_recipe = parse_environment('SOMETHING="$(echo a; echo b; echo c)"') + + # pass the existing process env so PATH is available + environment_dict = environment_recipe.as_dictionary(os.environ.copy()) + + assert environment_dict.get('SOMETHING') == 'a\nb\nc' + + +def test_substitution_with_backslash(): + environment_recipe = parse_environment('PATH2="somewhere_else;$PATH1"') + + # pass the existing process env so PATH is available + environment_dict = environment_recipe.as_dictionary(prev_environment={ + 'PATH1': 'c:\\folder\\' + }) + + assert environment_dict.get('PATH2') == 'somewhere_else;c:\\folder\\' + + +def test_awkwardly_quoted_variable(): + environment_recipe = parse_environment('VAR2=something"like this""$VAR1"$VAR1$(echo "theres more")"$(echo "and more!")"') + + # pass the existing process env so PATH is available + environment_dict = environment_recipe.as_dictionary({'VAR1': 'but wait'}) + + assert environment_dict.get('VAR2') == 'somethinglike thisbut waitbut waittheres moreand more!' diff --git a/unit_test/main_tests/conftest.py b/unit_test/main_tests/conftest.py index cd896915..3f684b6c 100644 --- a/unit_test/main_tests/conftest.py +++ b/unit_test/main_tests/conftest.py @@ -1,6 +1,6 @@ -import os import subprocess import sys +from pathlib import Path import pytest @@ -18,7 +18,7 @@ class ArgsInterceptor: self.kwargs = kwargs -MOCK_PACKAGE_DIR = 'some_package_dir' +MOCK_PACKAGE_DIR = Path('some_package_dir') @pytest.fixture(autouse=True) @@ -31,28 +31,33 @@ def mock_protection(monkeypatch): def fail_on_call(*args, **kwargs): raise RuntimeError("This should never be called") + def ignore_call(*args, **kwargs): + pass + monkeypatch.setattr(subprocess, 'Popen', fail_on_call) monkeypatch.setattr(util, 'download', fail_on_call) monkeypatch.setattr(windows, 'build', fail_on_call) monkeypatch.setattr(linux, 'build', fail_on_call) monkeypatch.setattr(macos, 'build', fail_on_call) + monkeypatch.setattr(Path, 'mkdir', ignore_call) + @pytest.fixture(autouse=True) def fake_package_dir(monkeypatch): ''' Monkey-patch enough for the main() function to run ''' - real_os_path_exists = os.path.exists + real_path_exists = Path.exists - def mock_os_path_exists(path): - if path == os.path.join(MOCK_PACKAGE_DIR, 'setup.py'): + def mock_path_exists(path): + if path == MOCK_PACKAGE_DIR / 'setup.py': return True else: - return real_os_path_exists(path) + return real_path_exists(path) - monkeypatch.setattr(os.path, 'exists', mock_os_path_exists) - monkeypatch.setattr(sys, 'argv', ['cibuildwheel', MOCK_PACKAGE_DIR]) + monkeypatch.setattr(Path, 'exists', mock_path_exists) + monkeypatch.setattr(sys, 'argv', ['cibuildwheel', str(MOCK_PACKAGE_DIR)]) @pytest.fixture(params=['linux', 'macos', 'windows']) diff --git a/unit_test/main_tests/main_options_test.py b/unit_test/main_tests/main_options_test.py index 633e9cc5..c7e6bfdd 100644 --- a/unit_test/main_tests/main_options_test.py +++ b/unit_test/main_tests/main_options_test.py @@ -1,5 +1,6 @@ import sys from fnmatch import fnmatch +from pathlib import Path import pytest @@ -12,9 +13,9 @@ from cibuildwheel.util import BuildSelector def test_output_dir(platform, intercepted_build_args, monkeypatch): - OUTPUT_DIR = 'some_output_dir' + OUTPUT_DIR = Path('some_output_dir') - monkeypatch.setenv('CIBW_OUTPUT_DIR', OUTPUT_DIR) + monkeypatch.setenv('CIBW_OUTPUT_DIR', str(OUTPUT_DIR)) main() @@ -24,14 +25,14 @@ def test_output_dir(platform, intercepted_build_args, monkeypatch): def test_output_dir_default(platform, intercepted_build_args, monkeypatch): main() - assert intercepted_build_args.args[0].output_dir == 'wheelhouse' + assert intercepted_build_args.args[0].output_dir == Path('wheelhouse') @pytest.mark.parametrize('also_set_environment', [False, True]) def test_output_dir_argument(also_set_environment, platform, intercepted_build_args, monkeypatch): - OUTPUT_DIR = 'some_output_dir' + OUTPUT_DIR = Path('some_output_dir') - monkeypatch.setattr(sys, 'argv', sys.argv + ['--output-dir', OUTPUT_DIR]) + monkeypatch.setattr(sys, 'argv', sys.argv + ['--output-dir', str(OUTPUT_DIR)]) if also_set_environment: monkeypatch.setenv('CIBW_OUTPUT_DIR', 'not_this_output_dir') @@ -232,3 +233,21 @@ def test_build_selector_migrations(intercepted_build_args, monkeypatch, option_n assert intercepted_build_selector.build_patterns == build_selector_patterns else: assert intercepted_build_selector.skip_patterns == build_selector_patterns + + +@pytest.mark.parametrize('before_all', ["", None, 'test text']) +@pytest.mark.parametrize('platform_specific', [False, True]) +def test_before_all(before_all, platform_specific, platform, intercepted_build_args, monkeypatch): + if before_all is not None: + if platform_specific: + monkeypatch.setenv('CIBW_BEFORE_ALL_' + platform.upper(), before_all) + monkeypatch.setenv('CIBW_BEFORE_ALL', 'overwritten') + else: + monkeypatch.setenv('CIBW_BEFORE_ALL', before_all) + + main() + + if before_all is None: + before_all = "" + + assert intercepted_build_args.args[0].before_all == before_all