Merge pull request #386 from joerick/docker-exec

From Linux bash script to Python control flow
This commit is contained in:
Joe Rickerby
2020-07-11 19:28:40 +01:00
committed by GitHub
11 changed files with 565 additions and 223 deletions
+5 -1
View File
@@ -10,7 +10,11 @@ if __name__ == '__main__':
os.chdir(Path(__file__).resolve().parents[1]) os.chdir(Path(__file__).resolve().parents[1])
# run the unit tests # 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 # run the integration tests
subprocess.check_call([sys.executable, '-m', 'pytest', '-x', '--durations', '0', 'test']) subprocess.check_call([sys.executable, '-m', 'pytest', '-x', '--durations', '0', 'test'])
+44 -8
View File
@@ -1,17 +1,24 @@
import shlex import shlex
import subprocess import subprocess
from typing import Callable, Dict, List, NamedTuple, Optional, Sequence
from typing import Dict, NamedTuple
import bashlex # type: ignore import bashlex # type: ignore
# a function that takes a shell command and the environment, and returns the result
EnvironmentExecutor = Callable[[str, Dict[str, str]], str]
def local_environment_executor(command: str, env: Dict[str, str]) -> str:
return subprocess.check_output(shlex.split(command), env=env, universal_newlines=True)
class NodeExecutionContext(NamedTuple): class NodeExecutionContext(NamedTuple):
environment: Dict[str, str] environment: Dict[str, str]
input: 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: if not value:
# empty string evaluates to empty string # empty string evaluates to empty string
# (but trips up bashlex) # (but trips up bashlex)
@@ -26,7 +33,7 @@ def evaluate(value: str, environment: Dict[str, str]) -> str:
return evaluate_node( return evaluate_node(
value_word_node, value_word_node,
context=NodeExecutionContext(environment=environment, input=value) context=NodeExecutionContext(environment=environment, input=value, executor=executor or local_environment_executor)
) )
@@ -34,11 +41,13 @@ def evaluate_node(node: bashlex.ast.node, context: NodeExecutionContext) -> str:
if node.kind == 'word': if node.kind == 'word':
return evaluate_word_node(node, context=context) return evaluate_word_node(node, context=context)
elif node.kind == 'commandsubstitution': 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': elif node.kind == 'parameter':
return evaluate_parameter_node(node, context=context) return evaluate_parameter_node(node, context=context)
else: 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: def evaluate_word_node(node: bashlex.ast.node, context: NodeExecutionContext) -> str:
@@ -65,9 +74,36 @@ def evaluate_word_node(node: bashlex.ast.node, context: NodeExecutionContext) ->
def evaluate_command_node(node: bashlex.ast.node, context: NodeExecutionContext) -> str: def evaluate_command_node(node: bashlex.ast.node, context: NodeExecutionContext) -> str:
words = [evaluate_node(part, context=context) for part in node.parts] 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):
words = [evaluate_node(part, context=context) for part in nodes]
command = ' '.join(words) command = ' '.join(words)
return subprocess.check_output(shlex.split(command), env=context.environment, universal_newlines=True) return context.executor(command, context.environment)
def evaluate_parameter_node(node: bashlex.ast.node, context: NodeExecutionContext) -> str: def evaluate_parameter_node(node: bashlex.ast.node, context: NodeExecutionContext) -> str:
+189
View File
@@ -0,0 +1,189 @@
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=False):
self.docker_image = docker_image
self.simulate_32_bit = simulate_32_bit
def __enter__(self) -> 'DockerContainer':
self.name = f'cibuildwheel-{uuid.uuid4()}'
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
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: str, environment: Dict[str, str]) -> str:
# used as an EnvironmentExecutor to evaluate commands and capture output
return self.call(shlex.split(command), env=environment)
def shell_quote(path: PurePath) -> str:
return shlex.quote(str(path))
+7 -5
View File
@@ -1,6 +1,6 @@
import bashlex # type: ignore import bashlex # type: ignore
from typing import Dict, List, Mapping from typing import Dict, List, Mapping, Optional
from . import bashlex_eval from . import bashlex_eval
@@ -46,9 +46,9 @@ class EnvironmentAssignment:
self.name = name self.name = name
self.value = value 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''' '''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: def as_shell_assignment(self) -> str:
return f'export {self.name}={self.value}' return f'export {self.name}={self.value}'
@@ -61,11 +61,13 @@ class ParsedEnvironment:
def __init__(self, assignments: List[EnvironmentAssignment]): def __init__(self, assignments: List[EnvironmentAssignment]):
self.assignments = assignments self.assignments = assignments
def as_dictionary(self, prev_environment: Mapping[str, str]) -> Dict[str, str]: def as_dictionary(self,
prev_environment: Mapping[str, str],
executor: Optional[bashlex_eval.EnvironmentExecutor] = None) -> Dict[str, str]:
environment = dict(**prev_environment) environment = dict(**prev_environment)
for assignment in self.assignments: for assignment in self.assignments:
value = assignment.evaluated_value(environment=environment) value = assignment.evaluated_value(environment=environment, executor=executor)
environment[assignment.name] = value environment[assignment.name] = value
return environment return environment
+123 -203
View File
@@ -1,27 +1,14 @@
import os
import platform import platform
import shlex
import subprocess import subprocess
import sys import sys
import textwrap import textwrap
import uuid from os import PathLike
from pathlib import Path, PurePath from pathlib import Path, PurePath
from typing import List, NamedTuple, Union
from typing import List, NamedTuple, Optional, Union from .docker_container import DockerContainer
from .util import (BuildOptions, BuildSelector,
from .util import ( get_build_verbosity_extra_flags, prepare_command)
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
)
def matches_platform(identifier: str) -> bool: def matches_platform(identifier: str) -> bool:
@@ -48,37 +35,41 @@ def matches_platform(identifier: str) -> bool:
class PythonConfiguration(NamedTuple): class PythonConfiguration(NamedTuple):
version: str version: str
identifier: 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]: def get_python_configurations(build_selector: BuildSelector) -> List[PythonConfiguration]:
python_configurations = [ 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_str='/opt/python/cp27-cp27m'),
PythonConfiguration(version='2.7', identifier='cp27-manylinux_x86_64', path='/opt/python/cp27-cp27mu'), 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='/opt/python/cp35-cp35m'), 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='/opt/python/cp36-cp36m'), 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='/opt/python/cp37-cp37m'), 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='/opt/python/cp38-cp38'), PythonConfiguration(version='3.8', identifier='cp38-manylinux_x86_64', path_str='/opt/python/cp38-cp38'),
PythonConfiguration(version='2.7', identifier='cp27-manylinux_i686', path='/opt/python/cp27-cp27m'), PythonConfiguration(version='2.7', identifier='cp27-manylinux_i686', path_str='/opt/python/cp27-cp27m'),
PythonConfiguration(version='2.7', identifier='cp27-manylinux_i686', path='/opt/python/cp27-cp27mu'), PythonConfiguration(version='2.7', identifier='cp27-manylinux_i686', path_str='/opt/python/cp27-cp27mu'),
PythonConfiguration(version='3.5', identifier='cp35-manylinux_i686', path='/opt/python/cp35-cp35m'), PythonConfiguration(version='3.5', identifier='cp35-manylinux_i686', path_str='/opt/python/cp35-cp35m'),
PythonConfiguration(version='3.6', identifier='cp36-manylinux_i686', path='/opt/python/cp36-cp36m'), PythonConfiguration(version='3.6', identifier='cp36-manylinux_i686', path_str='/opt/python/cp36-cp36m'),
PythonConfiguration(version='3.7', identifier='cp37-manylinux_i686', path='/opt/python/cp37-cp37m'), PythonConfiguration(version='3.7', identifier='cp37-manylinux_i686', path_str='/opt/python/cp37-cp37m'),
PythonConfiguration(version='3.8', identifier='cp38-manylinux_i686', path='/opt/python/cp38-cp38'), PythonConfiguration(version='3.8', identifier='cp38-manylinux_i686', path_str='/opt/python/cp38-cp38'),
PythonConfiguration(version='2.7', identifier='pp27-manylinux_x86_64', path='/opt/python/pp27-pypy_73'), 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='/opt/python/pp36-pypy36_pp73'), 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='/opt/python/cp35-cp35m'), PythonConfiguration(version='3.5', identifier='cp35-manylinux_aarch64', path_str='/opt/python/cp35-cp35m'),
PythonConfiguration(version='3.6', identifier='cp36-manylinux_aarch64', path='/opt/python/cp36-cp36m'), PythonConfiguration(version='3.6', identifier='cp36-manylinux_aarch64', path_str='/opt/python/cp36-cp36m'),
PythonConfiguration(version='3.7', identifier='cp37-manylinux_aarch64', path='/opt/python/cp37-cp37m'), PythonConfiguration(version='3.7', identifier='cp37-manylinux_aarch64', path_str='/opt/python/cp37-cp37m'),
PythonConfiguration(version='3.8', identifier='cp38-manylinux_aarch64', path='/opt/python/cp38-cp38'), PythonConfiguration(version='3.8', identifier='cp38-manylinux_aarch64', path_str='/opt/python/cp38-cp38'),
PythonConfiguration(version='3.5', identifier='cp35-manylinux_ppc64le', path='/opt/python/cp35-cp35m'), PythonConfiguration(version='3.5', identifier='cp35-manylinux_ppc64le', path_str='/opt/python/cp35-cp35m'),
PythonConfiguration(version='3.6', identifier='cp36-manylinux_ppc64le', path='/opt/python/cp36-cp36m'), PythonConfiguration(version='3.6', identifier='cp36-manylinux_ppc64le', path_str='/opt/python/cp36-cp36m'),
PythonConfiguration(version='3.7', identifier='cp37-manylinux_ppc64le', path='/opt/python/cp37-cp37m'), PythonConfiguration(version='3.7', identifier='cp37-manylinux_ppc64le', path_str='/opt/python/cp37-cp37m'),
PythonConfiguration(version='3.8', identifier='cp38-manylinux_ppc64le', path='/opt/python/cp38-cp38'), PythonConfiguration(version='3.8', identifier='cp38-manylinux_ppc64le', path_str='/opt/python/cp38-cp38'),
PythonConfiguration(version='3.5', identifier='cp35-manylinux_s390x', path='/opt/python/cp35-cp35m'), PythonConfiguration(version='3.5', identifier='cp35-manylinux_s390x', path_str='/opt/python/cp35-cp35m'),
PythonConfiguration(version='3.6', identifier='cp36-manylinux_s390x', path='/opt/python/cp36-cp36m'), PythonConfiguration(version='3.6', identifier='cp36-manylinux_s390x', path_str='/opt/python/cp36-cp36m'),
PythonConfiguration(version='3.7', identifier='cp37-manylinux_s390x', path='/opt/python/cp37-cp37m'), PythonConfiguration(version='3.7', identifier='cp37-manylinux_s390x', path_str='/opt/python/cp37-cp37m'),
PythonConfiguration(version='3.8', identifier='cp38-manylinux_s390x', path='/opt/python/cp38-cp38'), PythonConfiguration(version='3.8', identifier='cp38-manylinux_s390x', path_str='/opt/python/cp38-cp38'),
] ]
# skip builds as required # skip builds as required
return [c for c in python_configurations if matches_platform(c.identifier) and build_selector(c.identifier)] return [c for c in python_configurations if matches_platform(c.identifier) and build_selector(c.identifier)]
@@ -111,201 +102,130 @@ def build(options: BuildOptions) -> None:
raise Exception('package_dir must be inside the working directory') raise Exception('package_dir must be inside the working directory')
container_package_dir = PurePath('/project') / abs_package_dir.relative_to(cwd) container_package_dir = PurePath('/project') / abs_package_dir.relative_to(cwd)
container_output_dir = PurePath('/output')
for implementation, platform_tag, docker_image in platforms: 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)] platform_configs = [c for c in python_configurations if c.identifier.startswith(implementation) and c.identifier.endswith(platform_tag)]
if not platform_configs: if not platform_configs:
continue 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: try:
call(['docker', 'cp', '.', container_name + ':/project']) with DockerContainer(docker_image, simulate_32_bit=platform_tag.endswith('i686')) as docker:
docker.copy_into(Path.cwd(), Path('/project'))
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)
if options.before_all: before_all_prepared = prepare_command(options.before_all, project='/project', package=container_package_dir)
call( docker.call(['sh', '-c', before_all_prepared], env=env)
['docker', 'exec', '-i', container_name] + shell_cmd,
universal_newlines=True,
input='''
PS4=' + '
set -o errexit for config in platform_configs:
set -o xtrace dependency_constraint_flags: List[Union[str, PathLike]] = []
# add a modern Python interpreter to PATH so it can be used by BEFORE_ALL if options.dependency_constraints:
# commands constraints_file = options.dependency_constraints.get_for_python_version(config.version)
export PATH=/opt/python/cp38-cp38:$PATH container_constraints_file = PurePath('/constraints.txt')
{environment_exports} docker.copy_into(constraints_file, container_constraints_file)
dependency_constraint_flags = ['-c', container_constraints_file]
sh -c {before_all} env = docker.get_environment()
'''.format( # put this config's python top of the list
environment_exports='\n'.join(options.environment.as_shell_commands()), python_bin = config.path / 'bin'
before_all=shlex.quote(prepare_command(options.before_all, project='/project', package=container_package_dir)) env['PATH'] = f'{python_bin}:{env["PATH"]}'
)
)
for config in platform_configs: env = options.environment.as_dictionary(env, executor=docker.environment_executor)
if options.dependency_constraints:
constraints_file = options.dependency_constraints.get_for_python_version(config.version)
# `docker cp` causes 'no space left on device' error when # check config python and pip are still on PATH
# a container is running and the host filesystem is which_python = docker.call(['which', 'python'], env=env, capture_output=True).strip()
# mounted. https://github.com/moby/moby/issues/38995 if PurePath(which_python) != python_bin / 'python':
# Use `docker exec` instead. 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)
with open(constraints_file, 'rb') as f: exit(1)
call(
['docker', 'exec', '-i', container_name, 'sh', '-c', 'cat > /constraints.txt'],
input=f.read(),
)
call( which_pip = docker.call(['which', 'pip'], env=env, capture_output=True).strip()
['docker', 'exec', '-i', container_name] + shell_cmd, if PurePath(which_pip) != python_bin / 'pip':
universal_newlines=True, 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)
input=''' exit(1)
# give xtrace output an extra level of indent inside docker
PS4=' + '
set -o errexit if options.before_build:
set -o xtrace before_build_prepared = prepare_command(options.before_build, project='/project', package=container_package_dir)
mkdir -p /output docker.call(['sh', '-c', before_build_prepared], env=env)
cd /project
PYBIN="{config_python_bin}" 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])
export PATH="$PYBIN:$PATH" docker.call([
{environment_exports} 'pip', 'wheel',
container_package_dir,
'-w', built_wheel_dir,
'--no-deps',
*get_build_verbosity_extra_flags(options.build_verbosity)
], env=env)
# check the active python and pip are in PYBIN built_wheel = docker.glob(built_wheel_dir, '*.whl')[0]
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
if [ ! -z {before_build} ]; then repaired_wheel_dir = temp_dir / 'repaired_wheel'
sh -c {before_build} docker.call(['rm', '-rf', repaired_wheel_dir])
fi docker.call(['mkdir', '-p', repaired_wheel_dir])
# Build the wheel if built_wheel.name.endswith('none-any.whl') or not options.repair_command:
rm -rf /tmp/built_wheel docker.call(['mv', built_wheel, repaired_wheel_dir])
mkdir /tmp/built_wheel else:
pip wheel {package_dir} -w /tmp/built_wheel --no-deps {build_verbosity_flag} repair_command_prepared = prepare_command(options.repair_command, wheel=built_wheel, dest_dir=repaired_wheel_dir)
built_wheel=(/tmp/built_wheel/*.whl) docker.call(['sh', '-c', repair_command_prepared], env=env)
# repair the wheel repaired_wheels = docker.glob(repaired_wheel_dir, '*.whl')
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 [ ! -z {test_command} ]; then if options.test_command:
# 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.
pip install {dependency_install_flags} virtualenv docker.call(['pip', 'install', 'virtualenv', *dependency_constraint_flags], env=env)
venv_dir=`mktemp -d`/venv venv_dir = PurePath(docker.call(['mktemp', '-d'], capture_output=True).strip()) / 'venv'
python -m virtualenv --no-download "$venv_dir"
# run the tests in a subshell to keep that `activate` docker.call(['python', '-m', 'virtualenv', '--no-download', venv_dir], env=env)
# script from polluting the env
(
source "$venv_dir/bin/activate"
echo "Running tests using `which python`" virtualenv_env = env.copy()
virtualenv_env['PATH'] = f"{venv_dir / 'bin'}:{virtualenv_env['PATH']}"
if [ ! -z {before_test} ]; then if options.before_test:
sh -c {before_test} before_test_prepared = prepare_command(options.before_test, project='/project', package=container_package_dir)
fi docker.call(['sh', '-c', before_test_prepared], env=virtualenv_env)
# Install the wheel we just built # Install the wheel we just built
# Note: If auditwheel produced two wheels, it's because the earlier produced wheel # 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 # conforms to multiple manylinux standards. These multiple versions of the wheel are
# functionally the same, differing only in name, wheel metadata, and possibly include # 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. # different external shared libraries. so it doesn't matter which one we run the tests on.
# Let's just pick the first one. # Let's just pick the first one.
pip install "${{repaired_wheels[0]}}"{test_extras} 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 # Install any requirements to run the tests
if [ ! -z "{test_requires}" ]; then if options.test_requires:
pip install {test_requires} docker.call(['pip', 'install', *options.test_requires], env=virtualenv_env)
fi
# Run the tests from a different directory # Run the tests from a different directory
pushd $HOME test_command_prepared = prepare_command(options.test_command, project='/project', package=container_package_dir)
sh -c {test_command} docker.call(['sh', '-c', test_command_prepared], cwd='/root', env=virtualenv_env)
popd
)
# exit if tests failed (needed for older bash versions)
if [ $? -ne 0 ]; then
exit 1;
fi
# clean up # clean up test environment
rm -rf "$venv_dir" docker.call(['rm', '-rf', venv_dir])
fi
# we're all done here; move it to output # move repaired wheels to output
mv "${{repaired_wheels[@]}}" /output docker.call(['mkdir', '-p', container_output_dir])
for repaired_wheel in "${{repaired_wheels[@]}}"; do docker.call(['mv', *repaired_wheels, container_output_dir])
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 '',
)
)
# copy the output back into the host # copy the output back into the host
call(['docker', 'cp', docker.copy_out(container_output_dir, options.output_dir)
container_name + ':/output/.',
str(options.output_dir.resolve())])
except subprocess.CalledProcessError as error: except subprocess.CalledProcessError as error:
print(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)
finally:
# Still gets executed, even when 'exit(1)' gets called
call(['docker', 'rm', '--force', '-v', container_name])
def troubleshoot(package_dir: Path, error: Exception) -> None: def troubleshoot(package_dir: Path, error: Exception) -> None:
+2
View File
@@ -6,3 +6,5 @@ pymdown-extensions
pip-tools pip-tools
requests requests
click click
mypy
typing-extensions
+1 -1
View File
@@ -1,5 +1,5 @@
[flake8] [flake8]
ignore = E501,W503,E741 ignore = E501,W503,E741,E226
application-import-names = cibuildwheel application-import-names = cibuildwheel
exclude = exclude =
cibuildwheel/resources/, cibuildwheel/resources/,
+17 -5
View File
@@ -44,7 +44,7 @@ def test(tmp_path):
assert set(actual_wheels) == set(expected_wheels) 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' project_dir = tmp_path / 'project'
output_dir = tmp_path / 'output' output_dir = tmp_path / 'output'
@@ -54,8 +54,20 @@ def test_overridden_path(tmp_path):
# mess up PATH, somehow # mess up PATH, somehow
with pytest.raises(subprocess.CalledProcessError): with pytest.raises(subprocess.CalledProcessError):
utils.cibuildwheel_run(project_dir, output_dir=output_dir, add_env={ if utils.platform == 'linux':
'CIBW_ENVIRONMENT': '''SOMETHING="$(mkdir new_path && touch new_path/python)" PATH="$(realpath new_path):$PATH"''', utils.cibuildwheel_run(project_dir, output_dir=output_dir, add_env={
'CIBW_ENVIRONMENT_WINDOWS': '''SOMETHING="$(mkdir new_path && type nul > new_path/python.exe)" PATH="$CD\\new_path;$PATH"''', '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={
'CIBW_ENVIRONMENT': f'''PATH="{new_path}{os.pathsep}$PATH"''',
})
assert len(os.listdir(output_dir)) == 0 assert len(os.listdir(output_dir)) == 0
captured = capfd.readouterr()
assert "python available on PATH doesn't match our installed instance" in captured.err
+21
View File
@@ -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)
+147
View File
@@ -0,0 +1,147 @@
import platform
import random
import shutil
import subprocess
import textwrap
from pathlib import Path, PurePath
import pytest
from cibuildwheel.docker_container import DockerContainer
# 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_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()
+9
View File
@@ -94,3 +94,12 @@ def test_no_vars_pass_through():
environment_dict = environment_recipe.as_dictionary(prev_environment={'CIBUILDWHEEL': 'awesome'}) environment_dict = environment_recipe.as_dictionary(prev_environment={'CIBUILDWHEEL': 'awesome'})
assert environment_dict == {'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'