Merge pull request #458 from joerick/logger

Improve CI logs using fold groups
This commit is contained in:
Joe Rickerby
2020-11-26 17:44:49 +00:00
committed by GitHub
13 changed files with 646 additions and 217 deletions
+11
View File
@@ -37,10 +37,21 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: | run: |
python -m pip install -r requirements-dev.txt python -m pip install -r requirements-dev.txt
- name: Install Visual C++ for Python 2.7 - name: Install Visual C++ for Python 2.7
if: runner.os == 'Windows' if: runner.os == 'Windows'
run: | run: |
choco install vcpython27 -f -y choco install vcpython27 -f -y
- name: Get PR labels
id: pr-labels
uses: joerick/pr-labels-action@v1.0.6
- name: Sample build
if: contains(steps.pr-labels.outputs.labels, ' ci-sample-build ')
run: |
python bin/sample_build.py
- name: Test cibuildwheel - name: Test cibuildwheel
run: | run: |
python ./bin/run_tests.py python ./bin/run_tests.py
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env python3
import os
import subprocess
import sys
import argparse
import tempfile
from pathlib import Path
if __name__ == '__main__':
# move cwd to the project root
os.chdir(Path(__file__).resolve().parents[1])
parser = argparse.ArgumentParser(description='Runs a sample build')
parser.add_argument('project_python_path', nargs='?', default='test.test_0_basic.basic_project')
options = parser.parse_args()
project_dir = tempfile.mkdtemp()
subprocess.run([
sys.executable, '-m', 'test.test_projects',
options.project_python_path, project_dir
], check=True)
exit(subprocess.run(['cibuildwheel'], cwd=project_dir).returncode)
+9 -13
View File
@@ -21,6 +21,7 @@ from cibuildwheel.util import (
BuildSelector, BuildSelector,
DependencyConstraints, DependencyConstraints,
Unbuffered, Unbuffered,
detect_ci_provider,
resources_dir, resources_dir,
) )
@@ -47,12 +48,6 @@ def get_option_from_environment(option_name: str, platform: Optional[str] = None
return os.environ.get(option_name, default) return os.environ.get(option_name, default)
def strtobool(val: str) -> bool:
if val.lower() in ('y', 'yes', 't', 'true', 'on', '1'):
return True
return False
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Build wheels for all the platforms.', description='Build wheels for all the platforms.',
@@ -96,13 +91,14 @@ def main() -> None:
if args.platform != 'auto': if args.platform != 'auto':
platform = args.platform platform = args.platform
else: else:
ci = strtobool(os.environ.get('CI', 'false')) or 'BITRISE_BUILD_NUMBER' in os.environ or 'AZURE_HTTP_USER_AGENT' in os.environ or 'GITHUB_WORKFLOW' in os.environ ci_provider = detect_ci_provider()
if not ci: if ci_provider is None:
print('cibuildwheel: Unable to detect platform. cibuildwheel should run on your CI server, ' print(textwrap.dedent('''
'Travis CI, AppVeyor, Azure Pipelines, GitHub Actions and CircleCI are supported. You ' cibuildwheel: Unable to detect platform. cibuildwheel should run on your CI server;
'can run on your development machine or other CI providers using the --platform argument. ' Travis CI, AppVeyor, Azure Pipelines, GitHub Actions, CircleCI, and Gitlab are
'Check --help output for more information.', supported. You can run on your development machine or other CI providers using the
file=sys.stderr) --platform argument. Check --help output for more information.
'''), file=sys.stderr)
exit(2) exit(2)
if sys.platform.startswith('linux'): if sys.platform.startswith('linux'):
platform = 'linux' platform = 'linux'
+1 -1
View File
@@ -73,7 +73,7 @@ class DockerContainer:
self.process.terminate() self.process.terminate()
self.process.wait() self.process.wait()
subprocess.run(['docker', 'rm', '--force', '-v', self.name]) subprocess.run(['docker', 'rm', '--force', '-v', self.name], stdout=subprocess.DEVNULL)
self.name = None self.name = None
def copy_into(self, from_path: Path, to_path: PurePath) -> None: def copy_into(self, from_path: Path, to_path: PurePath) -> None:
+22 -2
View File
@@ -7,6 +7,7 @@ from pathlib import Path, PurePath
from typing import List, NamedTuple, Union from typing import List, NamedTuple, Union
from .docker_container import DockerContainer from .docker_container import DockerContainer
from .logger import log
from .util import (BuildOptions, BuildSelector, NonPlatformWheelError, from .util import (BuildOptions, BuildSelector, NonPlatformWheelError,
get_build_verbosity_extra_flags, prepare_command) get_build_verbosity_extra_flags, prepare_command)
@@ -83,7 +84,7 @@ def get_python_configurations(build_selector: BuildSelector) -> List[PythonConfi
def build(options: BuildOptions) -> None: def build(options: BuildOptions) -> None:
try: try:
subprocess.check_call(['docker', '--version']) subprocess.check_output(['docker', '--version'])
except Exception: except Exception:
print('cibuildwheel: Docker not found. Docker is required to run Linux builds. ' print('cibuildwheel: Docker not found. Docker is required to run Linux builds. '
'If you\'re building on Travis CI, add `services: [docker]` to your .travis.yml.' 'If you\'re building on Travis CI, add `services: [docker]` to your .travis.yml.'
@@ -117,10 +118,15 @@ def build(options: BuildOptions) -> None:
continue continue
try: try:
log.step(f'Starting Docker image {docker_image}...')
with DockerContainer(docker_image, simulate_32_bit=platform_tag.endswith('i686'), cwd=container_project_path) as docker: with DockerContainer(docker_image, simulate_32_bit=platform_tag.endswith('i686'), cwd=container_project_path) as docker:
log.step('Copying project into Docker...')
docker.copy_into(Path.cwd(), container_project_path) docker.copy_into(Path.cwd(), container_project_path)
if options.before_all: if options.before_all:
log.step('Running before_all...')
env = docker.get_environment() env = docker.get_environment()
env['PATH'] = f'/opt/python/cp38-cp38/bin:{env["PATH"]}' env['PATH'] = f'/opt/python/cp38-cp38/bin:{env["PATH"]}'
env = options.environment.as_dictionary(env, executor=docker.environment_executor) env = options.environment.as_dictionary(env, executor=docker.environment_executor)
@@ -129,6 +135,8 @@ def build(options: BuildOptions) -> None:
docker.call(['sh', '-c', before_all_prepared], env=env) docker.call(['sh', '-c', before_all_prepared], env=env)
for config in platform_configs: for config in platform_configs:
log.build_start(config.identifier)
dependency_constraint_flags: List[Union[str, PathLike]] = [] dependency_constraint_flags: List[Union[str, PathLike]] = []
if options.dependency_constraints: if options.dependency_constraints:
@@ -138,6 +146,8 @@ def build(options: BuildOptions) -> None:
docker.copy_into(constraints_file, container_constraints_file) docker.copy_into(constraints_file, container_constraints_file)
dependency_constraint_flags = ['-c', container_constraints_file] dependency_constraint_flags = ['-c', container_constraints_file]
log.step('Setting up build environment...')
env = docker.get_environment() env = docker.get_environment()
# put this config's python top of the list # put this config's python top of the list
@@ -158,9 +168,12 @@ def build(options: BuildOptions) -> None:
exit(1) exit(1)
if options.before_build: if options.before_build:
log.step('Running before_build...')
before_build_prepared = prepare_command(options.before_build, project=container_project_path, package=container_package_dir) 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) docker.call(['sh', '-c', before_build_prepared], env=env)
log.step('Building wheel...')
temp_dir = PurePath('/tmp/cibuildwheel') temp_dir = PurePath('/tmp/cibuildwheel')
built_wheel_dir = temp_dir / 'built_wheel' built_wheel_dir = temp_dir / 'built_wheel'
docker.call(['rm', '-rf', built_wheel_dir]) docker.call(['rm', '-rf', built_wheel_dir])
@@ -184,6 +197,7 @@ def build(options: BuildOptions) -> None:
raise NonPlatformWheelError() raise NonPlatformWheelError()
if options.repair_command: if options.repair_command:
log.step('Repairing wheel...')
repair_command_prepared = prepare_command(options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir) 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) docker.call(['sh', '-c', repair_command_prepared], env=env)
else: else:
@@ -192,6 +206,8 @@ def build(options: BuildOptions) -> None:
repaired_wheels = docker.glob(repaired_wheel_dir, '*.whl') repaired_wheels = docker.glob(repaired_wheel_dir, '*.whl')
if options.test_command: if options.test_command:
log.step('Testing wheel...')
# set up a virtual environment to install and test from, to make sure # set up a virtual environment to install and test from, to make sure
# there are no dependencies that were pulled in at build time. # there are no dependencies that were pulled in at build time.
docker.call(['pip', 'install', 'virtualenv', *dependency_constraint_flags], env=env) docker.call(['pip', 'install', 'virtualenv', *dependency_constraint_flags], env=env)
@@ -230,10 +246,14 @@ def build(options: BuildOptions) -> None:
docker.call(['mkdir', '-p', container_output_dir]) docker.call(['mkdir', '-p', container_output_dir])
docker.call(['mv', *repaired_wheels, container_output_dir]) docker.call(['mv', *repaired_wheels, container_output_dir])
log.build_end()
log.step('Copying wheels back to host...')
# copy the output back into the host # copy the output back into the host
docker.copy_out(container_output_dir, options.output_dir) docker.copy_out(container_output_dir, options.output_dir)
log.step_end()
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
print(f'Command {error.cmd} failed with code {error.returncode}. {error.stdout}') log.error(f'Command {error.cmd} failed with code {error.returncode}. {error.stdout}')
troubleshoot(options.package_dir, error) troubleshoot(options.package_dir, error)
exit(1) exit(1)
+263
View File
@@ -0,0 +1,263 @@
import codecs
import os
import re
import sys
import time
from typing import Optional, Union
from cibuildwheel.util import CIProvider, detect_ci_provider
DEFAULT_FOLD_PATTERN = ('{name}', '')
FOLD_PATTERNS = {
'azure': ('##[group]{name}', '##[endgroup]'),
'travis': ('travis_fold:start:{identifier}\n{name}', 'travis_fold:end:{identifier}'),
'github': ('::group::{name}', '::endgroup::{name}'),
}
PLATFORM_IDENTIFIER_DESCIPTIONS = {
'manylinux_x86_64': 'manylinux x86_64',
'manylinux_i686': 'manylinux i686',
'manylinux_aarch64': 'manylinux aarch64',
'manylinux_ppc64le': 'manylinux ppc64le',
'manylinux_s390x': 'manylinux s390x',
'win32': 'Windows 32bit',
'win_amd64': 'Windows 64bit',
'macosx_x86_64': 'macOS x86_64',
}
class Logger:
fold_mode: str
colors_enabled: bool
unicode_enabled: bool
active_build_identifier: Optional[str] = None
build_start_time: Optional[float] = None
step_start_time: Optional[float] = None
active_fold_group_name: Optional[str] = None
def __init__(self):
if sys.platform == 'win32' and hasattr(sys.stdout, 'reconfigure'):
# the encoding on Windows can be a 1-byte charmap, but all CIs
# support utf8, so we hardcode that
sys.stdout.reconfigure(encoding='utf8')
self.unicode_enabled = file_supports_unicode(sys.stdout)
ci_provider = detect_ci_provider()
if ci_provider == CIProvider.azure_pipelines:
self.fold_mode = 'azure'
self.colors_enabled = True
elif ci_provider == CIProvider.github_actions:
self.fold_mode = 'github'
self.colors_enabled = True
elif ci_provider == CIProvider.travis_ci:
self.fold_mode = 'travis'
self.colors_enabled = True
elif ci_provider == CIProvider.appveyor:
self.fold_mode = 'disabled'
self.colors_enabled = True
else:
self.fold_mode = 'disabled'
self.colors_enabled = file_supports_color(sys.stdout)
def build_start(self, identifier: str):
self.step_end()
c = self.colors
description = build_description_from_identifier(identifier)
print()
print(f'{c.bold}{c.blue}Building {identifier} wheel{c.end}')
print(f'{description}')
print()
self.build_start_time = time.time()
self.active_build_identifier = identifier
def build_end(self):
assert self.build_start_time is not None
assert self.active_build_identifier is not None
self.step_end()
c = self.colors
s = self.symbols
duration = time.time() - self.build_start_time
print()
print(f'{c.green}{s.done} {c.end}{self.active_build_identifier} finished in {duration:.2f}s')
self.build_start_time = None
self.active_build_identifier = None
def step(self, step_description: str):
self.step_end()
self.step_start_time = time.time()
self._start_fold_group(step_description)
def step_end(self, success=True):
if self.step_start_time is not None:
self._end_fold_group()
c = self.colors
s = self.symbols
duration = time.time() - self.step_start_time
if success:
print(f'{c.green}{s.done} {c.end}{duration:.2f}s'.rjust(78))
else:
print(f'{c.red}{s.error} {c.end}{duration:.2f}s'.rjust(78))
self.step_start_time = None
def error(self, error: Union[Exception, str]):
self.step_end(success=False)
print()
if self.fold_mode == 'github':
print(f'::error::{error}')
else:
c = self.colors
print(f'{c.bright_red}Error{c.end} {error}')
def _start_fold_group(self, name: str):
self._end_fold_group()
self.active_fold_group_name = name
fold_start_pattern = FOLD_PATTERNS.get(self.fold_mode, DEFAULT_FOLD_PATTERN)[0]
identifier = self._fold_group_identifier(name)
print(fold_start_pattern.format(name=self.active_fold_group_name, identifier=identifier))
print()
sys.stdout.flush()
def _end_fold_group(self):
if self.active_fold_group_name:
fold_start_pattern = FOLD_PATTERNS.get(self.fold_mode, DEFAULT_FOLD_PATTERN)[1]
identifier = self._fold_group_identifier(self.active_fold_group_name)
print(fold_start_pattern.format(name=self.active_fold_group_name, identifier=identifier))
sys.stdout.flush()
self.active_fold_group_name = None
def _fold_group_identifier(self, name: str):
'''
Travis doesn't like fold groups identifiers that have spaces in. This
method converts them to ascii identifiers
'''
# whitespace to underscores
identifier = re.sub(r'\s+', '_', name)
# remove non-alphanum
identifier = re.sub(r'[^A-Za-z\d_]+', '', identifier)
# trim underscores
identifier = identifier.strip('_')
# lowercase, shorten
return identifier.lower()[:20]
@property
def colors(self):
if self.colors_enabled:
return Colors.enabled
else:
return Colors.disabled
@property
def symbols(self):
if self.unicode_enabled:
return Symbols.unicode
else:
return Symbols.ascii
def build_description_from_identifier(identifier: str):
python_identifier, _, platform_identifier = identifier.partition('-')
build_description = ''
python_interpreter = python_identifier[0:2]
python_version = python_identifier[2:4]
if python_interpreter == 'cp':
build_description += 'CPython'
elif python_interpreter == 'pp':
build_description += 'PyPy'
else:
raise Exception('unknown python')
build_description += f' {python_version[0]}.{python_version[1]} '
try:
build_description += PLATFORM_IDENTIFIER_DESCIPTIONS[platform_identifier]
except KeyError as e:
raise Exception('unknown platform') from e
return build_description
class Colors:
class Enabled:
red = '\033[31m'
green = '\033[32m'
yellow = '\033[33m'
blue = '\033[34m'
cyan = '\033[36m'
bright_red = '\033[91m'
bright_green = '\033[92m'
white = '\033[37m\033[97m'
bg_grey = '\033[48;5;235m'
bold = '\033[1m'
faint = '\033[2m'
end = '\033[0m'
class Disabled:
def __getattr__(self, attr: str) -> str:
return ''
enabled = Enabled()
disabled = Disabled()
class Symbols:
class Unicode:
done = ''
error = ''
class Ascii:
done = 'done'
error = 'failed'
unicode = Unicode()
ascii = Ascii()
def file_supports_color(file_obj):
"""
Returns True if the running system's terminal supports color.
"""
plat = sys.platform
supported_platform = (plat != 'win32' or 'ANSICON' in os.environ)
is_a_tty = file_is_a_tty(file_obj)
return (supported_platform and is_a_tty)
def file_is_a_tty(file_obj):
return hasattr(file_obj, 'isatty') and file_obj.isatty()
def file_supports_unicode(file_obj):
encoding = getattr(file_obj, 'encoding', None)
if not encoding:
return False
codec_info = codecs.lookup(encoding)
return ('utf' in codec_info.name)
'''
Global instance of the Logger.
'''
# (there's only one stdout per-process, so a global instance is justified)
log = Logger()
+26 -9
View File
@@ -9,9 +9,10 @@ from pathlib import Path
from typing import Dict, List, NamedTuple, Optional, Sequence, Union from typing import Dict, List, NamedTuple, Optional, Sequence, Union
from .environment import ParsedEnvironment from .environment import ParsedEnvironment
from .util import (BuildOptions, BuildSelector, NonPlatformWheelError, download, from .logger import log
get_build_verbosity_extra_flags, get_pip_script, from .util import (BuildOptions, BuildSelector, NonPlatformWheelError,
prepare_command, install_certifi_script) download, get_build_verbosity_extra_flags, get_pip_script,
install_certifi_script, prepare_command)
def call(args: Union[str, Sequence[Union[str, PathLike]]], 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:
@@ -116,13 +117,18 @@ def install_pypy(version: str, url: str) -> Path:
def setup_python(python_configuration: PythonConfiguration, def setup_python(python_configuration: PythonConfiguration,
dependency_constraint_flags: Sequence[Union[str, PathLike]], dependency_constraint_flags: Sequence[Union[str, PathLike]],
environment: ParsedEnvironment) -> Dict[str, str]: environment: ParsedEnvironment) -> Dict[str, str]:
if python_configuration.identifier.startswith('cp'): implementation_id = python_configuration.identifier.split("-")[0]
log.step(f'Installing Python {implementation_id}...')
if implementation_id.startswith('cp'):
installation_bin_path = install_cpython(python_configuration.version, python_configuration.url) installation_bin_path = install_cpython(python_configuration.version, python_configuration.url)
elif python_configuration.identifier.startswith('pp'): elif implementation_id.startswith('pp'):
installation_bin_path = install_pypy(python_configuration.version, python_configuration.url) installation_bin_path = install_pypy(python_configuration.version, python_configuration.url)
else: else:
raise ValueError("Unknown Python implementation") raise ValueError("Unknown Python implementation")
log.step('Setting up build environment...')
env = os.environ.copy() env = os.environ.copy()
env['PATH'] = os.pathsep.join([ env['PATH'] = os.pathsep.join([
str(SYMLINKS_DIR), str(SYMLINKS_DIR),
@@ -156,7 +162,6 @@ def setup_python(python_configuration: PythonConfiguration,
if which_pip != '/tmp/cibw_bin/pip': 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) 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) exit(1)
call(['pip', 'install', '--upgrade', 'setuptools', 'wheel', 'delocate', *dependency_constraint_flags], env=env)
# Set MACOSX_DEPLOYMENT_TARGET to 10.9, if the user didn't set it. # 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 # CPython 3.5 defaults to 10.6, and pypy defaults to 10.7, causing
@@ -170,6 +175,9 @@ def setup_python(python_configuration: PythonConfiguration,
# https://github.com/python/cpython/blob/a5ed2fe0eedefa1649aa93ee74a0bafc8e628a10/Lib/_osx_support.py#L260 # https://github.com/python/cpython/blob/a5ed2fe0eedefa1649aa93ee74a0bafc8e628a10/Lib/_osx_support.py#L260
env.setdefault('ARCHFLAGS', '-arch x86_64') env.setdefault('ARCHFLAGS', '-arch x86_64')
log.step('Installing build tools...')
call(['pip', 'install', '--upgrade', 'setuptools', 'wheel', 'delocate', *dependency_constraint_flags], env=env)
return env return env
@@ -178,7 +186,9 @@ def build(options: BuildOptions) -> None:
built_wheel_dir = temp_dir / 'built_wheel' built_wheel_dir = temp_dir / 'built_wheel'
repaired_wheel_dir = temp_dir / 'repaired_wheel' repaired_wheel_dir = temp_dir / 'repaired_wheel'
try:
if options.before_all: if options.before_all:
log.step('Running before_all...')
env = options.environment.as_dictionary(prev_environment=os.environ) env = options.environment.as_dictionary(prev_environment=os.environ)
before_all_prepared = prepare_command(options.before_all, project='.', package=options.package_dir) before_all_prepared = prepare_command(options.before_all, project='.', package=options.package_dir)
call([before_all_prepared], shell=True, env=env) call([before_all_prepared], shell=True, env=env)
@@ -186,6 +196,8 @@ def build(options: BuildOptions) -> None:
python_configurations = get_python_configurations(options.build_selector) python_configurations = get_python_configurations(options.build_selector)
for config in python_configurations: for config in python_configurations:
log.build_start(config.identifier)
dependency_constraint_flags: Sequence[Union[str, PathLike]] = [] dependency_constraint_flags: Sequence[Union[str, PathLike]] = []
if options.dependency_constraints: if options.dependency_constraints:
dependency_constraint_flags = [ dependency_constraint_flags = [
@@ -194,12 +206,12 @@ def build(options: BuildOptions) -> None:
env = setup_python(config, dependency_constraint_flags, options.environment) env = setup_python(config, dependency_constraint_flags, options.environment)
# run the before_build command
if options.before_build: if options.before_build:
log.step('Running before_build...')
before_build_prepared = prepare_command(options.before_build, project='.', package=options.package_dir) before_build_prepared = prepare_command(options.before_build, project='.', package=options.package_dir)
call(before_build_prepared, env=env, shell=True) call(before_build_prepared, env=env, shell=True)
# build the wheel log.step('Building wheel...')
if built_wheel_dir.exists(): if built_wheel_dir.exists():
shutil.rmtree(built_wheel_dir) shutil.rmtree(built_wheel_dir)
built_wheel_dir.mkdir(parents=True) built_wheel_dir.mkdir(parents=True)
@@ -216,7 +228,6 @@ def build(options: BuildOptions) -> None:
built_wheel = next(built_wheel_dir.glob('*.whl')) built_wheel = next(built_wheel_dir.glob('*.whl'))
# repair the wheel
if repaired_wheel_dir.exists(): if repaired_wheel_dir.exists():
shutil.rmtree(repaired_wheel_dir) shutil.rmtree(repaired_wheel_dir)
repaired_wheel_dir.mkdir(parents=True) repaired_wheel_dir.mkdir(parents=True)
@@ -225,6 +236,7 @@ def build(options: BuildOptions) -> None:
raise NonPlatformWheelError() raise NonPlatformWheelError()
if options.repair_command: if options.repair_command:
log.step('Repairing wheel...')
repair_command_prepared = prepare_command(options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir) repair_command_prepared = prepare_command(options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir)
call(repair_command_prepared, env=env, shell=True) call(repair_command_prepared, env=env, shell=True)
else: else:
@@ -233,6 +245,7 @@ def build(options: BuildOptions) -> None:
repaired_wheel = next(repaired_wheel_dir.glob('*.whl')) repaired_wheel = next(repaired_wheel_dir.glob('*.whl'))
if options.test_command: if options.test_command:
log.step('Testing wheel...')
# set up a virtual environment to install and test from, to make sure # set up a virtual environment to install and test from, to make sure
# there are no dependencies that were pulled in at build time. # there are no dependencies that were pulled in at build time.
call(['pip', 'install', 'virtualenv', *dependency_constraint_flags], env=env) call(['pip', 'install', 'virtualenv', *dependency_constraint_flags], env=env)
@@ -277,3 +290,7 @@ def build(options: BuildOptions) -> None:
# we're all done here; move it to output (overwrite existing) # we're all done here; move it to output (overwrite existing)
shutil.move(str(repaired_wheel), options.output_dir) shutil.move(str(repaired_wheel), options.output_dir)
log.build_end()
except subprocess.CalledProcessError as error:
log.error(f'Command {error.cmd} failed with code {error.returncode}. {error.stdout}')
exit(1)
+45 -6
View File
@@ -1,14 +1,15 @@
import os import os
import textwrap
import certifi
import urllib.request
import ssl import ssl
import textwrap
import urllib.request
from enum import Enum
from fnmatch import fnmatch from fnmatch import fnmatch
from pathlib import Path from pathlib import Path
from time import sleep from time import sleep
from typing import Dict, List, NamedTuple, Optional, Union from typing import Dict, List, NamedTuple, Optional, Union
import certifi
from .environment import ParsedEnvironment from .environment import ParsedEnvironment
@@ -42,6 +43,9 @@ class BuildSelector:
return match_any(self.build_patterns) and not match_any(self.skip_patterns) return match_any(self.build_patterns) and not match_any(self.skip_patterns)
def __repr__(self) -> str: def __repr__(self) -> str:
if not self.skip_patterns:
return f'BuildSelector({" ".join(self.build_patterns)!r})'
else:
return f'BuildSelector({" ".join(self.build_patterns)!r} - {" ".join(self.skip_patterns)!r})' return f'BuildSelector({" ".join(self.build_patterns)!r} - {" ".join(self.skip_patterns)!r})'
@@ -113,8 +117,8 @@ class DependencyConstraints:
else: else:
return self.base_file_path return self.base_file_path
def __str__(self): def __repr__(self):
return f"File '{self.base_file_path}'" return f'{self.__class__.__name__}{self.base_file_path!r})'
class BuildOptions(NamedTuple): class BuildOptions(NamedTuple):
@@ -152,3 +156,38 @@ class NonPlatformWheelError(Exception):
''') ''')
super().__init__(message) super().__init__(message)
def strtobool(val: str) -> bool:
if val.lower() in ('y', 'yes', 't', 'true', 'on', '1'):
return True
return False
class CIProvider(str, Enum):
travis_ci = 'travis'
appveyor = 'appveyor'
circle_ci = 'circle_ci'
azure_pipelines = 'azure_pipelines'
github_actions = 'github_actions'
gitlab = 'gitlab'
other = 'other'
def detect_ci_provider() -> Optional[CIProvider]:
if 'TRAVIS' in os.environ:
return CIProvider.travis_ci
elif 'APPVEYOR' in os.environ:
return CIProvider.appveyor
elif 'CIRCLECI' in os.environ:
return CIProvider.circle_ci
elif 'AZURE_HTTP_USER_AGENT' in os.environ:
return CIProvider.azure_pipelines
elif 'GITHUB_ACTIONS' in os.environ:
return CIProvider.github_actions
elif 'GITLAB_CI' in os.environ:
return CIProvider.gitlab
elif strtobool(os.environ.get('CI', 'false')):
return CIProvider.other
else:
return None
+28 -10
View File
@@ -7,11 +7,13 @@ from os import PathLike
from pathlib import Path from pathlib import Path
from typing import Dict, List, NamedTuple, Optional, Sequence, Union from typing import Dict, List, NamedTuple, Optional, Sequence, Union
from zipfile import ZipFile from zipfile import ZipFile
import toml import toml
from .environment import ParsedEnvironment from .environment import ParsedEnvironment
from .util import (BuildOptions, BuildSelector, NonPlatformWheelError, download, from .logger import log
get_build_verbosity_extra_flags, get_pip_script, from .util import (BuildOptions, BuildSelector, NonPlatformWheelError,
download, get_build_verbosity_extra_flags, get_pip_script,
prepare_command) prepare_command)
IS_RUNNING_ON_AZURE = Path('C:\\hostedtoolcache').exists() IS_RUNNING_ON_AZURE = Path('C:\\hostedtoolcache').exists()
@@ -109,11 +111,15 @@ def install_pypy(version: str, arch: str, url: str) -> Path:
def setup_python(python_configuration: PythonConfiguration, dependency_constraint_flags: Sequence[Union[str, PathLike]], environment: ParsedEnvironment) -> Dict[str, str]: def setup_python(python_configuration: PythonConfiguration, dependency_constraint_flags: Sequence[Union[str, PathLike]], environment: ParsedEnvironment) -> Dict[str, str]:
nuget = Path('C:\\cibw\\nuget.exe') nuget = Path('C:\\cibw\\nuget.exe')
if not nuget.exists(): if not nuget.exists():
log.step('Downloading nuget...')
download('https://dist.nuget.org/win-x86-commandline/latest/nuget.exe', nuget) download('https://dist.nuget.org/win-x86-commandline/latest/nuget.exe', nuget)
if python_configuration.identifier.startswith('cp'): implementation_id = python_configuration.identifier.split("-")[0]
log.step(f'Installing Python {implementation_id}...')
if implementation_id.startswith('cp'):
installation_path = install_cpython(python_configuration.version, python_configuration.arch, nuget) installation_path = install_cpython(python_configuration.version, python_configuration.arch, nuget)
elif python_configuration.identifier.startswith('pp'): elif implementation_id.startswith('pp'):
assert python_configuration.url is not None assert python_configuration.url is not None
installation_path = install_pypy(python_configuration.version, python_configuration.arch, python_configuration.url) installation_path = install_pypy(python_configuration.version, python_configuration.arch, python_configuration.url)
else: else:
@@ -121,6 +127,8 @@ def setup_python(python_configuration: PythonConfiguration, dependency_constrain
assert (installation_path / 'python.exe').exists() assert (installation_path / 'python.exe').exists()
log.step('Setting up build environment...')
# set up PATH and environment variables for run_with_env # set up PATH and environment variables for run_with_env
env = os.environ.copy() env = os.environ.copy()
env['PYTHON_VERSION'] = python_configuration.version env['PYTHON_VERSION'] = python_configuration.version
@@ -151,7 +159,8 @@ def setup_python(python_configuration: PythonConfiguration, dependency_constrain
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) 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) exit(1)
# prepare the Python environment log.step('Installing build tools...')
call(['python', '-m', 'pip', 'install', '--upgrade', 'pip', *dependency_constraint_flags], env=env) call(['python', '-m', 'pip', 'install', '--upgrade', 'pip', *dependency_constraint_flags], env=env)
call(['pip', '--version'], env=env) call(['pip', '--version'], env=env)
call(['pip', 'install', '--upgrade', 'setuptools', 'wheel', *dependency_constraint_flags], env=env) call(['pip', 'install', '--upgrade', 'setuptools', 'wheel', *dependency_constraint_flags], env=env)
@@ -182,6 +191,7 @@ def pep_518_cp35_workaround(package_dir: Path, env: Dict[str, str]) -> None:
) )
if requirements: if requirements:
log.step('Performing PEP518 workaround...')
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
reqfile = Path(d) / "requirements.txt" reqfile = Path(d) / "requirements.txt"
with reqfile.open("w") as f: with reqfile.open("w") as f:
@@ -195,17 +205,18 @@ def build(options: BuildOptions) -> None:
built_wheel_dir = temp_dir / 'built_wheel' built_wheel_dir = temp_dir / 'built_wheel'
repaired_wheel_dir = temp_dir / 'repaired_wheel' repaired_wheel_dir = temp_dir / 'repaired_wheel'
# install nuget as best way to provide python try:
nuget = Path('C:\\cibw\\nuget.exe')
download('https://dist.nuget.org/win-x86-commandline/latest/nuget.exe', nuget)
if options.before_all: if options.before_all:
log.step('Running before_all...')
env = options.environment.as_dictionary(prev_environment=os.environ) env = options.environment.as_dictionary(prev_environment=os.environ)
before_all_prepared = prepare_command(options.before_all, project='.', package=options.package_dir) before_all_prepared = prepare_command(options.before_all, project='.', package=options.package_dir)
shell(before_all_prepared, env=env) shell(before_all_prepared, env=env)
python_configurations = get_python_configurations(options.build_selector) python_configurations = get_python_configurations(options.build_selector)
for config in python_configurations: for config in python_configurations:
log.build_start(config.identifier)
dependency_constraint_flags: Sequence[Union[str, PathLike]] = [] dependency_constraint_flags: Sequence[Union[str, PathLike]] = []
if options.dependency_constraints: if options.dependency_constraints:
dependency_constraint_flags = [ dependency_constraint_flags = [
@@ -217,6 +228,7 @@ def build(options: BuildOptions) -> None:
# run the before_build command # run the before_build command
if options.before_build: if options.before_build:
log.step('Running before_build...')
before_build_prepared = prepare_command(options.before_build, project='.', package=options.package_dir) 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)
@@ -225,7 +237,7 @@ def build(options: BuildOptions) -> None:
if config.version.startswith('3.5'): if config.version.startswith('3.5'):
pep_518_cp35_workaround(options.package_dir, env) pep_518_cp35_workaround(options.package_dir, env)
# build the wheel log.step('Building wheel...')
if built_wheel_dir.exists(): if built_wheel_dir.exists():
shutil.rmtree(built_wheel_dir) shutil.rmtree(built_wheel_dir)
built_wheel_dir.mkdir(parents=True) built_wheel_dir.mkdir(parents=True)
@@ -250,6 +262,7 @@ def build(options: BuildOptions) -> None:
raise NonPlatformWheelError() raise NonPlatformWheelError()
if options.repair_command: if options.repair_command:
log.step('Repairing wheel...')
repair_command_prepared = prepare_command(options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir) repair_command_prepared = prepare_command(options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir)
shell(repair_command_prepared, env=env) shell(repair_command_prepared, env=env)
else: else:
@@ -258,6 +271,7 @@ def build(options: BuildOptions) -> None:
repaired_wheel = next(repaired_wheel_dir.glob('*.whl')) repaired_wheel = next(repaired_wheel_dir.glob('*.whl'))
if options.test_command: if options.test_command:
log.step('Testing wheel...')
# set up a virtual environment to install and test from, to make sure # set up a virtual environment to install and test from, to make sure
# there are no dependencies that were pulled in at build time. # there are no dependencies that were pulled in at build time.
call(['pip', 'install', 'virtualenv', *dependency_constraint_flags], env=env) call(['pip', 'install', 'virtualenv', *dependency_constraint_flags], env=env)
@@ -306,3 +320,7 @@ def build(options: BuildOptions) -> None:
# we're all done here; move it to output (remove if already exists) # we're all done here; move it to output (remove if already exists)
shutil.move(str(repaired_wheel), options.output_dir) shutil.move(str(repaired_wheel), options.output_dir)
log.build_end()
except subprocess.CalledProcessError as error:
log.error(f'Command {error.cmd} failed with code {error.returncode}. {error.stdout}')
exit(1)
+21 -3
View File
@@ -1,7 +1,9 @@
import textwrap
import platform import platform
from . import test_projects import textwrap
from . import utils import pytest
from cibuildwheel.logger import Logger
from . import test_projects, utils
basic_project = test_projects.new_c_project( basic_project = test_projects.new_c_project(
setup_py_add=textwrap.dedent(''' setup_py_add=textwrap.dedent('''
@@ -25,6 +27,22 @@ def test(tmp_path):
assert set(actual_wheels) == set(expected_wheels) assert set(actual_wheels) == set(expected_wheels)
@pytest.mark.skip(reason='to keep test output clean')
def test_sample_build(tmp_path, capfd):
project_dir = tmp_path / 'project'
basic_project.generate(project_dir)
# build the wheels, and let the output passthrough to the caller, so
# we can see how it looks
with capfd.disabled():
logger = Logger()
logger.step('test_sample_build')
try:
utils.cibuildwheel_run(project_dir)
finally:
logger.step_end()
def test_build_identifiers(tmp_path): def test_build_identifiers(tmp_path):
project_dir = tmp_path / 'project' project_dir = tmp_path / 'project'
basic_project.generate(project_dir) basic_project.generate(project_dir)
+8 -1
View File
@@ -11,21 +11,28 @@ def main():
prog="python -m test.test_projects", prog="python -m test.test_projects",
description='Generate a test project to check it out' description='Generate a test project to check it out'
) )
parser.add_argument('--open', action='store_true', help='''
Open the generated project in a file explorer
''')
parser.add_argument('PROJECT', help=''' parser.add_argument('PROJECT', help='''
Python path to a project object. E.g. test.test_0_basic.basic_project Python path to a project object. E.g. test.test_0_basic.basic_project
''') ''')
parser.add_argument('OUTPUT', nargs='?', help='''
Path to output dir. If no dir is passed, a tempdir will be generated.
''')
options = parser.parse_args() options = parser.parse_args()
module, _, name = options.PROJECT.rpartition('.') module, _, name = options.PROJECT.rpartition('.')
project = getattr(importlib.import_module(module), name) project = getattr(importlib.import_module(module), name)
project_dir = Path(tempfile.mkdtemp()) project_dir = Path(options.OUTPUT or tempfile.mkdtemp())
project.generate(project_dir) project.generate(project_dir)
print('Project generated at', project_dir) print('Project generated at', project_dir)
print() print()
if options.open:
if sys.platform == 'darwin': if sys.platform == 'darwin':
subprocess.check_call(['open', '--', project_dir]) subprocess.check_call(['open', '--', project_dir])
elif sys.platform == 'linux2': elif sys.platform == 'linux2':
+13 -2
View File
@@ -103,15 +103,26 @@ def test_extras_require(tmp_path):
assert set(actual_wheels) == set(expected_wheels) assert set(actual_wheels) == set(expected_wheels)
project_with_a_failing_test = test_projects.new_c_project()
project_with_a_failing_test.files['test/spam_test.py'] = r'''
from unittest import TestCase
class TestSpam(TestCase):
def test_something(self):
self.fail('this test is supposed to fail')
'''
def test_failing_test(tmp_path): def test_failing_test(tmp_path):
"""Ensure a failing test causes cibuildwheel to error out and exit""" """Ensure a failing test causes cibuildwheel to error out and exit"""
project_dir = tmp_path / 'project' project_dir = tmp_path / 'project'
output_dir = tmp_path / 'output' output_dir = tmp_path / 'output'
project_with_a_test.generate(project_dir) project_with_a_failing_test.generate(project_dir)
with pytest.raises(subprocess.CalledProcessError): with pytest.raises(subprocess.CalledProcessError):
utils.cibuildwheel_run(project_dir, output_dir=output_dir, add_env={ utils.cibuildwheel_run(project_dir, output_dir=output_dir, add_env={
'CIBW_TEST_COMMAND': 'false', 'CIBW_TEST_REQUIRES': 'nose',
'CIBW_TEST_COMMAND': 'nosetests {project}/test',
# manylinux1 has a version of bash that's been shown to have # manylinux1 has a version of bash that's been shown to have
# problems with this, so let's check that. # problems with this, so let's check that.
'CIBW_MANYLINUX_I686_IMAGE': 'manylinux1', 'CIBW_MANYLINUX_I686_IMAGE': 'manylinux1',
+5 -1
View File
@@ -11,7 +11,11 @@ def test_unknown_platform_non_ci(monkeypatch, capsys):
monkeypatch.delenv('CI', raising=False) monkeypatch.delenv('CI', raising=False)
monkeypatch.delenv('BITRISE_BUILD_NUMBER', raising=False) monkeypatch.delenv('BITRISE_BUILD_NUMBER', raising=False)
monkeypatch.delenv('AZURE_HTTP_USER_AGENT', raising=False) monkeypatch.delenv('AZURE_HTTP_USER_AGENT', raising=False)
monkeypatch.delenv('GITHUB_WORKFLOW', raising=False) monkeypatch.delenv('TRAVIS', raising=False)
monkeypatch.delenv('APPVEYOR', raising=False)
monkeypatch.delenv('GITHUB_ACTIONS', raising=False)
monkeypatch.delenv('GITLAB_CI', raising=False)
monkeypatch.delenv('CIRCLECI', raising=False)
monkeypatch.delenv('CIBW_PLATFORM', raising=False) monkeypatch.delenv('CIBW_PLATFORM', raising=False)
with pytest.raises(SystemExit) as exit: with pytest.raises(SystemExit) as exit: