From 38f89e955faaea992086b70bd5bd474b28e61b1a Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Mon, 24 Jul 2017 22:38:48 +0100 Subject: [PATCH 01/28] Added test for environment and a script for local running --- .travis.yml | 4 +-- appveyor.yml | 2 +- bin/dev_run_test | 6 ++++ bin/run_test.py | 45 ++++++++++++++++++++++++++ run_tests.py => bin/run_tests.py | 4 ++- test/05_environment/environment.json | 3 ++ test/05_environment/setup.py | 18 +++++++++++ test/05_environment/spam.c | 48 ++++++++++++++++++++++++++++ test/05_environment/version.txt | 2 ++ 9 files changed, 128 insertions(+), 4 deletions(-) create mode 100755 bin/dev_run_test create mode 100755 bin/run_test.py rename run_tests.py => bin/run_tests.py (93%) mode change 100644 => 100755 create mode 100644 test/05_environment/environment.json create mode 100644 test/05_environment/setup.py create mode 100644 test/05_environment/spam.c create mode 100644 test/05_environment/version.txt diff --git a/.travis.yml b/.travis.yml index 9ac8583b..3e5ea3f2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -9,9 +9,9 @@ script: - | if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then pip install . - python ./run_tests.py + python ./bin/run_tests.py else # linux test requires root to clean up the wheelhouse (docker runs as root) sudo pip install . - sudo python ./run_tests.py + sudo python ./bin/run_tests.py fi diff --git a/appveyor.yml b/appveyor.yml index 2c7debeb..93560bed 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,3 +1,3 @@ build_script: - pip install . - - python ./run_tests.py + - python ./bin/run_tests.py diff --git a/bin/dev_run_test b/bin/dev_run_test new file mode 100755 index 00000000..86500be2 --- /dev/null +++ b/bin/dev_run_test @@ -0,0 +1,6 @@ +#!/bin/bash + +cd "$(dirname "$0")" +cd .. + +CIBW_PLATFORM=linux ./bin/run_test.py $1 diff --git a/bin/run_test.py b/bin/run_test.py new file mode 100755 index 00000000..2fa1cbc5 --- /dev/null +++ b/bin/run_test.py @@ -0,0 +1,45 @@ +#!/usr/bin/python + +from __future__ import print_function +import os, sys, subprocess, shutil, json, argparse +from glob import glob + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("test_project_dir") + args = parser.parse_args() + + test_project = os.path.abspath(args.test_project_dir) + + # move cwd to the project root + os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + if not os.path.exists(test_project): + print('No test project not found.', file=sys.stderr) + exit(2) + + # load project settings into environment + env_file = os.path.join(test_project, 'environment.json') + project_env = {} + if os.path.exists(env_file): + with open(env_file) as f: + project_env = json.load(f) + + # run the build + env = os.environ.copy() + project_env = {str(k): str(v) for k, v in project_env.items()} # unicode not allowed in env + env.update(project_env) + print('Building %s with environment %s' % (test_project, project_env)) + subprocess.check_call(['cibuildwheel', test_project], env=env) + wheels = glob('wheelhouse/*.whl') + print('%s built successfully. %i wheels built.' % (test_project, len(wheels))) + + # check some wheels were actually built + assert len(wheels) >= 4 + + # clean up + shutil.rmtree('wheelhouse') + + print('Project built successfully.') + +main() diff --git a/run_tests.py b/bin/run_tests.py old mode 100644 new mode 100755 similarity index 93% rename from run_tests.py rename to bin/run_tests.py index cc86cf48..748591bf --- a/run_tests.py +++ b/bin/run_tests.py @@ -1,9 +1,11 @@ +#!/usr/bin/python + from __future__ import print_function import os, sys, subprocess, shutil, json from glob import glob # move cwd to the project root -os.chdir(os.path.dirname(os.path.abspath(__file__))) +os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) test_projects = glob('test/??_*') diff --git a/test/05_environment/environment.json b/test/05_environment/environment.json new file mode 100644 index 00000000..582a1340 --- /dev/null +++ b/test/05_environment/environment.json @@ -0,0 +1,3 @@ +{ + "CIBW_ENVIRONMENT": "CIBW_TEST_VAR=\"a b c\" CIBW_TEST_VAR_2=1" +} diff --git a/test/05_environment/setup.py b/test/05_environment/setup.py new file mode 100644 index 00000000..21f71264 --- /dev/null +++ b/test/05_environment/setup.py @@ -0,0 +1,18 @@ +from setuptools import setup, Extension +import sys, os + +if sys.argv[-1] != '--name': + # 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') + + 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) + +setup( + name="spam", + ext_modules=[Extension('spam', sources=['spam.c'])], + version="0.1.0", +) diff --git a/test/05_environment/spam.c b/test/05_environment/spam.c new file mode 100644 index 00000000..d1ab0f22 --- /dev/null +++ b/test/05_environment/spam.c @@ -0,0 +1,48 @@ +#include + +static PyObject * +spam_system(PyObject *self, PyObject *args) +{ + const char *command; + int sts; + + if (!PyArg_ParseTuple(args, "s", &command)) + return NULL; + sts = system(command); + return PyLong_FromLong(sts); +} + +/* Module initialization */ + +#if PY_MAJOR_VERSION >= 3 + #define MOD_INIT(name) PyMODINIT_FUNC PyInit_##name(void) + #define MOD_DEF(m, name, doc, methods, module_state_size) \ + static struct PyModuleDef moduledef = { \ + PyModuleDef_HEAD_INIT, name, doc, module_state_size, methods, }; \ + m = PyModule_Create(&moduledef); + #define MOD_RETURN(m) return m; +#else + #define MOD_INIT(name) PyMODINIT_FUNC init##name(void) + #define MOD_DEF(m, name, doc, methods, module_state_size) \ + m = Py_InitModule3(name, methods, doc); + #define MOD_RETURN(m) return; +#endif + +static PyMethodDef module_methods[] = { + {"system", (PyCFunction)spam_system, METH_VARARGS, + "Execute a shell command."}, + {NULL} /* Sentinel */ +}; + +MOD_INIT(spam) +{ + PyObject* m; + + MOD_DEF(m, + "spam", + "Example module", + module_methods, + -1) + + MOD_RETURN(m) +} diff --git a/test/05_environment/version.txt b/test/05_environment/version.txt new file mode 100644 index 00000000..3e4354cb --- /dev/null +++ b/test/05_environment/version.txt @@ -0,0 +1,2 @@ +3.6.0 (default, Feb 7 2017, 23:55:32) +[GCC 4.8.2 20140120 (Red Hat 4.8.2-15)] \ No newline at end of file From 6aba5f81f1e47c6e403202bbcec6aed52b206c24 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Mon, 24 Jul 2017 22:39:37 +0100 Subject: [PATCH 02/28] Add a Linux implementation of environment --- cibuildwheel/__main__.py | 13 ++++++++++++- cibuildwheel/linux.py | 7 ++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index cf15f57c..9d3e0bc5 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -1,5 +1,5 @@ from __future__ import print_function -import argparse, os, subprocess, sys, textwrap +import argparse, os, subprocess, sys, textwrap, shlex import cibuildwheel import cibuildwheel.linux, cibuildwheel.windows, cibuildwheel.macos @@ -72,6 +72,16 @@ def main(): project_dir = args.project_dir before_build = get_option_from_environment('CIBW_BEFORE_BUILD', platform=platform) skip_config = os.environ.get('CIBW_SKIP', '') + environment_config = get_option_from_environment('CIBW_ENVIRONMENT', platform=platform) or '' + + environment = {} + for key_value in shlex.split(environment_config): + try: + key, value = key_value.split('=') + environment[key] = value + except: + print('cibuildwheel: Malformed environment option "%s"' % key_value, file=sys.stderr) + exit(2) skip = BuildSkipper(skip_config) @@ -103,6 +113,7 @@ def main(): test_requires=test_requires, before_build=before_build, skip=skip, + environment=environment, ) print_preamble(platform, build_options) diff --git a/cibuildwheel/linux.py b/cibuildwheel/linux.py index af6415aa..7e28208a 100644 --- a/cibuildwheel/linux.py +++ b/cibuildwheel/linux.py @@ -9,7 +9,7 @@ except ImportError: from pipes import quote as shlex_quote -def build(project_dir, package_name, output_dir, test_command, test_requires, before_build, skip): +def build(project_dir, package_name, output_dir, test_command, test_requires, before_build, skip, environment): try: subprocess.check_call(['docker', '--version']) except: @@ -52,6 +52,8 @@ def build(project_dir, package_name, output_dir, test_command, test_requires, be set -o xtrace cd /project + {environment_exports} + for PYBIN in {pybin_paths}; do # Setup rm -rf /tmp/built_wheel @@ -106,6 +108,9 @@ def build(project_dir, package_name, output_dir, test_command, test_requires, be before_build=shlex_quote( prepare_command(before_build, python='python', pip='pip') if before_build else '' ), + environment_exports=' '.join( + ('export %s=%s\n' % (key, shlex_quote(value)) for key, value in environment.items()) + ), ) docker_process = subprocess.Popen([ From 573098f38fbc4006095cbbc9c2a80c7e45c7da8b Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Mon, 14 Aug 2017 21:07:08 +0100 Subject: [PATCH 03/28] Mac/Windows stubs and test case for environment inheritance --- cibuildwheel/macos.py | 2 +- cibuildwheel/windows.py | 2 +- test/05_environment/environment.json | 2 +- test/05_environment/setup.py | 6 ++++++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py index d98b9731..e6ff34f1 100644 --- a/cibuildwheel/macos.py +++ b/cibuildwheel/macos.py @@ -10,7 +10,7 @@ except ImportError: from .util import prepare_command -def build(project_dir, package_name, output_dir, test_command, test_requires, before_build, skip): +def build(project_dir, package_name, output_dir, test_command, test_requires, before_build, skip, environment): PythonConfiguration = namedtuple('PythonConfiguration', ['version', 'identifier', 'url']) python_configurations = [ PythonConfiguration(version='2.7', identifier='cp27-macosx_10_6_intel', url='https://www.python.org/ftp/python/2.7.13/python-2.7.13-macosx10.6.pkg'), diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py index 32a17853..1486c91b 100644 --- a/cibuildwheel/windows.py +++ b/cibuildwheel/windows.py @@ -10,7 +10,7 @@ from glob import glob from .util import prepare_command -def build(project_dir, package_name, output_dir, test_command, test_requires, before_build, skip): +def build(project_dir, package_name, output_dir, test_command, test_requires, before_build, skip, environment): # run_with_env is a cmd file that sets the right environment variables to run_with_env = os.path.join(tempfile.gettempdir(), 'appveyor_run_with_env.cmd') if not os.path.exists(run_with_env): diff --git a/test/05_environment/environment.json b/test/05_environment/environment.json index 582a1340..c97c84d7 100644 --- a/test/05_environment/environment.json +++ b/test/05_environment/environment.json @@ -1,3 +1,3 @@ { - "CIBW_ENVIRONMENT": "CIBW_TEST_VAR=\"a b c\" CIBW_TEST_VAR_2=1" + "CIBW_ENVIRONMENT": "CIBW_TEST_VAR=\"a b c\" CIBW_TEST_VAR_2=1 PATH=$PATH:/opt/cibw_test_path" } diff --git a/test/05_environment/setup.py b/test/05_environment/setup.py index 21f71264..3278c944 100644 --- a/test/05_environment/setup.py +++ b/test/05_environment/setup.py @@ -5,11 +5,17 @@ if sys.argv[-1] != '--name': # 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') + 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 '/opt/cibw_test_path' not in PATH: + raise Exception('PATH should contain "/opt/cibw_test_path". It was "%s"' % PATH) + if '$PATH' in PATH: + raise Exception('$PATH should be expanded in PATH. It was "%s"' % PATH) + setup( name="spam", From e9eba3486948a77658a8d9b85441502dfb010229 Mon Sep 17 00:00:00 2001 From: Aymeric Augustin Date: Sun, 20 Aug 2017 14:16:07 +0200 Subject: [PATCH 04/28] Add websockets to list of users. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 160bc244..5e6eb012 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,7 @@ Working examples Here are some repos that use cibuildwheel. - [pyinstrument_cext](https://github.com/joerick/pyinstrument_cext) +- [websockets](https://github.com/aaugustin/websockets) > Add repo here! Send a PR. From 0a7a1b8fb3e38f4e8ca43d1fe6f104e6f207382a Mon Sep 17 00:00:00 2001 From: mayeut Date: Tue, 22 Aug 2017 21:41:13 +0200 Subject: [PATCH 05/28] Set CIBUILDWHEEL environment variable to 1 during build --- README.md | 11 +++++++++++ cibuildwheel/__main__.py | 4 ++++ cibuildwheel/linux.py | 2 ++ test/01_basic/setup.py | 5 +++++ 4 files changed, 22 insertions(+) diff --git a/README.md b/README.md index 5e6eb012..223277d0 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,17 @@ Usage `cibuildwheel` is not intended to run on your development machine. It will try to install packages globally; this is no good. Travis CI and Appveyor run their builds in isolated environments, so are ideal for this kind of script. +`cibuildwheel` defines the environment variable `CIBUILDWHEEL` to the value `1` allowing projects for which the C extension is optional to make it mandatory when building wheels. +An easy way to do it in Python 3 is through the `optional` named argument of `Extension` constructor in your `setup.py`: +```python +myextension = Extension( + "myextension", + ["myextension.c"], + optional=os.environ.get('CIBUILDWHEEL', '0') != '1', +) +``` + + ### Minimal setup - Create a `.travis.yml` file in your repo. diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index cf15f57c..c17b273e 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -75,6 +75,10 @@ def main(): skip = BuildSkipper(skip_config) + # Add CIBUILDWHEEL environment variable + # This needs to be passed on to the docker container in linux.py + os.environ['CIBUILDWHEEL'] = '1' + try: project_setup_py = os.path.join(project_dir, 'setup.py') name_output = subprocess.check_output([sys.executable, project_setup_py, '--name'], diff --git a/cibuildwheel/linux.py b/cibuildwheel/linux.py index af6415aa..b973089d 100644 --- a/cibuildwheel/linux.py +++ b/cibuildwheel/linux.py @@ -111,6 +111,8 @@ def build(project_dir, package_name, output_dir, test_command, test_requires, be docker_process = subprocess.Popen([ 'docker', 'run', + '--env', + 'CIBUILDWHEEL', '--rm', '-i', '-v', '%s:/project' % os.path.abspath(project_dir), diff --git a/test/01_basic/setup.py b/test/01_basic/setup.py index 866fa22c..48d7c00b 100644 --- a/test/01_basic/setup.py +++ b/test/01_basic/setup.py @@ -1,5 +1,10 @@ +import os + from setuptools import setup, Extension +if os.environ.get('CIBUILDWHEEL', '0') != '1': + raise Exception('CIBUILDWHEEL environment variable is not set to 1') + setup( name="spam", ext_modules=[Extension('spam', sources=['spam.c'])], From fd385a9bf85bc263675fdd6344b78df10aadb828 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 30 Aug 2017 19:05:06 +0100 Subject: [PATCH 06/28] Add some tests for the environment functionality --- unit_test/environment_test.py | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 unit_test/environment_test.py diff --git a/unit_test/environment_test.py b/unit_test/environment_test.py new file mode 100644 index 00000000..9ac295f6 --- /dev/null +++ b/unit_test/environment_test.py @@ -0,0 +1,35 @@ +from cibuildwheel.environment import parse_environment + + +def test_basic_parsing(): + environment_recipe = parse_environment('VAR=1 VBR=2') + + environment_dict = environment_recipe.as_dictionary( + prev_environment={}, + shell=lambda cmd: '') + environment_cmds = environment_recipe.as_shell_commands() + + assert environment_dict == {'VAR': '1', 'VBR': '2'} + assert environment_cmds == ['export VAR=1', 'export VBR=2'] + +def test_quotes(): + environment_recipe = parse_environment('A=1 VAR="1 NOT_A_VAR=2" VBR=\'vbr\'') + + environment_dict = environment_recipe.as_dictionary( + prev_environment={}, + shell=lambda cmd: '') + environment_cmds = environment_recipe.as_shell_commands() + + assert environment_dict == {'A': '1', 'VAR': '1 NOT_A_VAR=2', 'VBR': 'vbr'} + assert environment_cmds == ['export A=1', 'export VAR="1 NOT_A_VAR=2"', 'export ABR=vbr'] + +def test_inheritance(): + environment_recipe = parse_environment('PATH=$PATH:/usr/local/bin') + + environment_dict = environment_recipe.as_dictionary( + prev_environment={'PATH': '/usr/bin'}, + shell=lambda cmd: '') + environment_cmds = environment_recipe.as_shell_commands() + + assert environment_dict == {'PATH': '/usr/bin:/usr/local/bin'} + assert environment_cmds == ['export PATH=$PATH:/usr/local/bin'] From 0766cbd52fd90396d9f7dd16fa56e92719f51e10 Mon Sep 17 00:00:00 2001 From: Benjamin Wohlwend Date: Thu, 31 Aug 2017 09:49:57 +0200 Subject: [PATCH 07/28] use os.makedirs to allow nested output directory --- cibuildwheel/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index cf15f57c..e813ef24 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -108,7 +108,7 @@ def main(): print_preamble(platform, build_options) if not os.path.exists(output_dir): - os.mkdir(output_dir) + os.makedirs(output_dir) if platform == 'linux': cibuildwheel.linux.build(**build_options) From 5ae4da631f101d344f35e4751034f1daca4910d7 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Fri, 1 Sep 2017 13:24:12 +0100 Subject: [PATCH 08/28] Add environment code satisfying unit test --- cibuildwheel/bashlex_eval.py | 39 +++++++++++++++++++ cibuildwheel/environment.py | 73 +++++++++++++++++++++++++++++++++++ setup.py | 1 + unit_test/environment_test.py | 25 ++++++++---- 4 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 cibuildwheel/bashlex_eval.py create mode 100644 cibuildwheel/environment.py diff --git a/cibuildwheel/bashlex_eval.py b/cibuildwheel/bashlex_eval.py new file mode 100644 index 00000000..4437990b --- /dev/null +++ b/cibuildwheel/bashlex_eval.py @@ -0,0 +1,39 @@ +import subprocess, shlex + + +def evaluate_node(node, environment): + if node.kind == 'word': + return evaluate_word_node(node, environment=environment) + elif node.kind == 'commandsubstitution': + return evaluate_command_node(node.command, environment=environment) + elif node.kind == 'parameter': + return evaluate_parameter_node(node, environment=environment) + else: + raise ValueError('Unsupported bash construct: "%s"' % node.word) + + +def evaluate_word_node(node, environment): + letters = list(node.word) + + for part in node.parts: + part_start = part.pos[0] + part_end = part.pos[1] + + # Set all the characters in the part to None + for i in range(part_start, part_end): + letters[i] = None + + letters[part_start] = evaluate_node(part, environment=environment) + + # remove the None letters and concat + return ''.join(l for l in letters if l is not None) + + +def evaluate_command_node(node, environment): + words = [evaluate_node(part, environment=environment) for part in node.parts] + command = ' '.join(words) + return subprocess.check_output(shlex.split(command), env=environment) + + +def evaluate_parameter_node(node, environment): + return environment.get(node.value, '') diff --git a/cibuildwheel/environment.py b/cibuildwheel/environment.py new file mode 100644 index 00000000..6d6ed71f --- /dev/null +++ b/cibuildwheel/environment.py @@ -0,0 +1,73 @@ +import bashlex +from . import bashlex_eval + + +class EnvironmentParseError(Exception): + pass + + +def parse_environment(env_string): + env_items = split_env_items(env_string) + assignments = [EnvironmentAssignment(item) for item in env_items] + return ParsedEnvironment(assignments=assignments) + + +def split_env_items(env_string): + '''Splits space-separated variable assignments into a list of individual assignments. + + >>> split_env_items('VAR=abc') + ['VAR=abc'] + >>> split_env_items('VAR="a string" THING=3') + ['VAR="a string"', 'THING=3'] + >>> split_env_items('VAR="a string" THING=\\'single "quotes"\\'') + ['VAR="a string"', 'THING=\\'single "quotes"\\''] + >>> split_env_items('VAR="dont \\\\"forget\\\\" about backslashes"') + ['VAR="dont \\\\"forget\\\\" about backslashes"'] + ''' + return list(bashlex.split(env_string)) + + +class EnvironmentAssignment(object): + def __init__(self, assignment): + name, equals, value = assignment.partition('=') + if not equals: + raise EnvironmentParseError(assignment) + self.name = name + self.value = value + + if value: + command_node = bashlex.parsesingle(value) + + if len(command_node.parts) != 1: + raise ValueError('"%s" has too many parts' % value) + + self.value_word_node = command_node.parts[0] + else: + self.value_word_node = None + + def evaluated_value(self, environment): + '''Returns the value of this assignment, as evaluated in the environment''' + if self.value_word_node: + return bashlex_eval.evaluate_node(self.value_word_node, environment=environment) + else: + return '' + + def as_shell_assignment(self): + return 'export %s=%s' % (self.name, self.value) + + +class ParsedEnvironment(object): + def __init__(self, assignments): + self.assignments = assignments + + def as_dictionary(self, prev_environment): + environment = prev_environment.copy() + + for assignment in self.assignments: + value = assignment.evaluated_value(environment=environment) + environment[assignment.name] = value + + return environment + + def as_shell_commands(self): + return [a.as_shell_assignment() for a in self.assignments] diff --git a/setup.py b/setup.py index af684db1..1191f54b 100644 --- a/setup.py +++ b/setup.py @@ -9,6 +9,7 @@ except ImportError: setup( name='cibuildwheel', version='0.4.0', + install_requires=['bashlex'], description="Build Python wheels on CI with minimal configuration.", long_description='For readme please see http://github.com/joerick/cibuildwheel', author="Joe Rickerby", diff --git a/unit_test/environment_test.py b/unit_test/environment_test.py index 9ac295f6..a1556c87 100644 --- a/unit_test/environment_test.py +++ b/unit_test/environment_test.py @@ -5,8 +5,8 @@ def test_basic_parsing(): environment_recipe = parse_environment('VAR=1 VBR=2') environment_dict = environment_recipe.as_dictionary( - prev_environment={}, - shell=lambda cmd: '') + prev_environment={} + ) environment_cmds = environment_recipe.as_shell_commands() assert environment_dict == {'VAR': '1', 'VBR': '2'} @@ -16,20 +16,31 @@ def test_quotes(): environment_recipe = parse_environment('A=1 VAR="1 NOT_A_VAR=2" VBR=\'vbr\'') environment_dict = environment_recipe.as_dictionary( - prev_environment={}, - shell=lambda cmd: '') + prev_environment={} + ) environment_cmds = environment_recipe.as_shell_commands() assert environment_dict == {'A': '1', 'VAR': '1 NOT_A_VAR=2', 'VBR': 'vbr'} - assert environment_cmds == ['export A=1', 'export VAR="1 NOT_A_VAR=2"', 'export ABR=vbr'] + assert environment_cmds == ['export A=1', 'export VAR="1 NOT_A_VAR=2"', 'export VBR=\'vbr\''] def test_inheritance(): environment_recipe = parse_environment('PATH=$PATH:/usr/local/bin') environment_dict = environment_recipe.as_dictionary( - prev_environment={'PATH': '/usr/bin'}, - shell=lambda cmd: '') + prev_environment={'PATH': '/usr/bin'} + ) environment_cmds = environment_recipe.as_shell_commands() assert environment_dict == {'PATH': '/usr/bin:/usr/local/bin'} assert environment_cmds == ['export PATH=$PATH:/usr/local/bin'] + +def test_empty_var(): + environment_recipe = parse_environment('CFLAGS=') + + environment_dict = environment_recipe.as_dictionary( + prev_environment={'CFLAGS': '-Wall'} + ) + environment_cmds = environment_recipe.as_shell_commands() + + assert environment_dict == {'CFLAGS': ''} + assert environment_cmds == ['export CFLAGS='] From 9091533fccc635d0153f4989f7292c98fbee746b Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Fri, 1 Sep 2017 13:32:34 +0100 Subject: [PATCH 09/28] Run the unit tests in CI --- .travis.yml | 4 ++-- appveyor.yml | 2 +- bin/run_tests.py | 6 ++++++ requirements-dev.txt | 2 ++ 4 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 requirements-dev.txt diff --git a/.travis.yml b/.travis.yml index 3e5ea3f2..effe4142 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,10 +8,10 @@ matrix: script: - | if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then - pip install . + pip install -r requirements-dev.txt python ./bin/run_tests.py else # linux test requires root to clean up the wheelhouse (docker runs as root) - sudo pip install . + sudo pip install -r requirements-dev.txt sudo python ./bin/run_tests.py fi diff --git a/appveyor.yml b/appveyor.yml index 93560bed..e75f0975 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -1,3 +1,3 @@ build_script: - - pip install . + - pip install -r requirements-dev.txt - python ./bin/run_tests.py diff --git a/bin/run_tests.py b/bin/run_tests.py index 748591bf..2c8504af 100755 --- a/bin/run_tests.py +++ b/bin/run_tests.py @@ -7,6 +7,12 @@ from glob import glob # move cwd to the project root os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +### run the unit tests + +subprocess.check_call(['python', '-m', 'pytest', 'unit_test']) + +### run the integration tests + test_projects = glob('test/??_*') if len(test_projects) == 0: diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..ae60ed5f --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-e . +pytest From fcb681d28391e1463ce9a54eaf6fa5ad1691f84a Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Fri, 1 Sep 2017 13:33:29 +0100 Subject: [PATCH 10/28] Use parse_environment in the main code --- cibuildwheel/__main__.py | 16 ++++++++-------- cibuildwheel/environment.py | 6 ++++++ cibuildwheel/linux.py | 4 +--- cibuildwheel/macos.py | 1 + cibuildwheel/windows.py | 1 + 5 files changed, 17 insertions(+), 11 deletions(-) diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index 9d3e0bc5..c6c8d06f 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -3,6 +3,7 @@ import argparse, os, subprocess, sys, textwrap, shlex import cibuildwheel import cibuildwheel.linux, cibuildwheel.windows, cibuildwheel.macos +from cibuildwheel.environment import parse_environment, EnvironmentParseError from cibuildwheel.util import BuildSkipper def get_option_from_environment(option_name, platform=None): @@ -74,14 +75,13 @@ def main(): skip_config = os.environ.get('CIBW_SKIP', '') environment_config = get_option_from_environment('CIBW_ENVIRONMENT', platform=platform) or '' - environment = {} - for key_value in shlex.split(environment_config): - try: - key, value = key_value.split('=') - environment[key] = value - except: - print('cibuildwheel: Malformed environment option "%s"' % key_value, file=sys.stderr) - exit(2) + try: + environment = parse_environment(environment_config) + except (EnvironmentParseError, ValueError) as e: + print('cibuildwheel: Malformed environment option "%s"' % key_value, file=sys.stderr) + import traceback + traceback.print_exc(None, sys.stderr) + exit(2) skip = BuildSkipper(skip_config) diff --git a/cibuildwheel/environment.py b/cibuildwheel/environment.py index 6d6ed71f..5b5f45e1 100644 --- a/cibuildwheel/environment.py +++ b/cibuildwheel/environment.py @@ -55,6 +55,9 @@ class EnvironmentAssignment(object): def as_shell_assignment(self): return 'export %s=%s' % (self.name, self.value) + def __repr__(self): + return '%s=%s' % (self.name, self.value) + class ParsedEnvironment(object): def __init__(self, assignments): @@ -71,3 +74,6 @@ class ParsedEnvironment(object): def as_shell_commands(self): return [a.as_shell_assignment() for a in self.assignments] + + def __repr__(self): + return 'ParsedEnvironment(%r)' % [repr(a) for a in self.assignments] diff --git a/cibuildwheel/linux.py b/cibuildwheel/linux.py index 7e28208a..b49c4a11 100644 --- a/cibuildwheel/linux.py +++ b/cibuildwheel/linux.py @@ -108,9 +108,7 @@ def build(project_dir, package_name, output_dir, test_command, test_requires, be before_build=shlex_quote( prepare_command(before_build, python='python', pip='pip') if before_build else '' ), - environment_exports=' '.join( - ('export %s=%s\n' % (key, shlex_quote(value)) for key, value in environment.items()) - ), + environment_exports='\n'.join(environment.as_shell_commands()), ) docker_process = subprocess.Popen([ diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py index e6ff34f1..5bc9ed02 100644 --- a/cibuildwheel/macos.py +++ b/cibuildwheel/macos.py @@ -39,6 +39,7 @@ def build(project_dir, package_name, output_dir, test_command, test_requires, be '/Library/Frameworks/Python.framework/Versions/%s/bin' % config.version, env['PATH'], ]) + env = environment.as_dictionary(prev_environment=env) python = 'python3' if config.version[0] == '3' else 'python2' pip = 'pip3' if config.version[0] == '3' else 'pip2' diff --git a/cibuildwheel/windows.py b/cibuildwheel/windows.py index 1486c91b..1c7afe38 100644 --- a/cibuildwheel/windows.py +++ b/cibuildwheel/windows.py @@ -60,6 +60,7 @@ def build(project_dir, package_name, output_dir, test_command, test_requires, be os.path.join(config.path, 'Scripts'), env['PATH'] ]) + env = environment.as_dictionary(prev_environment=env) # for the logs - check we're running the right version of python shell(['python', '--version'], env=env) From f56ddda15739578b7190dae75bf0b543058de885 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Sat, 2 Sep 2017 22:09:32 +0100 Subject: [PATCH 11/28] Make shell evals work and add tests for that --- cibuildwheel/bashlex_eval.py | 58 +++++++++++++++++++++------- cibuildwheel/environment.py | 15 +------ test/05_environment/environment.json | 2 +- unit_test/environment_test.py | 22 +++++++++++ 4 files changed, 67 insertions(+), 30 deletions(-) diff --git a/cibuildwheel/bashlex_eval.py b/cibuildwheel/bashlex_eval.py index 4437990b..90a399fe 100644 --- a/cibuildwheel/bashlex_eval.py +++ b/cibuildwheel/bashlex_eval.py @@ -1,39 +1,67 @@ import subprocess, shlex +from collections import namedtuple +import bashlex + +NodeExecutionContext = namedtuple('NodeExecutionContext', ['environment', 'input']) + +def evaluate(value, environment): + if not value: + # empty string evaluates to empty string + # (but trips up bashlex) + return '' + + command_node = bashlex.parsesingle(value) + + if len(command_node.parts) != 1: + raise ValueError('"%s" has too many parts' % value) + + value_word_node = command_node.parts[0] + + return evaluate_node( + value_word_node, + context=NodeExecutionContext(environment=environment, input=value) + ) -def evaluate_node(node, environment): +def evaluate_node(node, context): if node.kind == 'word': - return evaluate_word_node(node, environment=environment) + return evaluate_word_node(node, context=context) elif node.kind == 'commandsubstitution': - return evaluate_command_node(node.command, environment=environment) + return evaluate_command_node(node.command, context=context) elif node.kind == 'parameter': - return evaluate_parameter_node(node, environment=environment) + return evaluate_parameter_node(node, context=context) else: raise ValueError('Unsupported bash construct: "%s"' % node.word) -def evaluate_word_node(node, environment): - letters = list(node.word) +def evaluate_word_node(node, context): + word_start = node.pos[0] + word_end = node.pos[1] + word_string = context.input[word_start:word_end] + letters = list(word_string) for part in node.parts: - part_start = part.pos[0] - part_end = part.pos[1] + part_start = part.pos[0] - word_start + part_end = part.pos[1] - word_start # Set all the characters in the part to None for i in range(part_start, part_end): letters[i] = None - letters[part_start] = evaluate_node(part, environment=environment) + letters[part_start] = evaluate_node(part, context=context) # remove the None letters and concat - return ''.join(l for l in letters if l is not None) + value = ''.join(l for l in letters if l is not None) + + # apply bash-like quotes/whitespace treatment + return ' '.join(word.strip() for word in shlex.split(value)) -def evaluate_command_node(node, environment): - words = [evaluate_node(part, environment=environment) for part in node.parts] +def evaluate_command_node(node, context): + words = [evaluate_node(part, context=context) for part in node.parts] command = ' '.join(words) - return subprocess.check_output(shlex.split(command), env=environment) + return subprocess.check_output(shlex.split(command), env=context.environment) -def evaluate_parameter_node(node, environment): - return environment.get(node.value, '') +def evaluate_parameter_node(node, context): + return context.environment.get(node.value, '') diff --git a/cibuildwheel/environment.py b/cibuildwheel/environment.py index 5b5f45e1..249dcc3a 100644 --- a/cibuildwheel/environment.py +++ b/cibuildwheel/environment.py @@ -35,22 +35,9 @@ class EnvironmentAssignment(object): self.name = name self.value = value - if value: - command_node = bashlex.parsesingle(value) - - if len(command_node.parts) != 1: - raise ValueError('"%s" has too many parts' % value) - - self.value_word_node = command_node.parts[0] - else: - self.value_word_node = None - def evaluated_value(self, environment): '''Returns the value of this assignment, as evaluated in the environment''' - if self.value_word_node: - return bashlex_eval.evaluate_node(self.value_word_node, environment=environment) - else: - return '' + return bashlex_eval.evaluate(self.value, environment=environment) def as_shell_assignment(self): return 'export %s=%s' % (self.name, self.value) diff --git a/test/05_environment/environment.json b/test/05_environment/environment.json index c97c84d7..f7c9b46c 100644 --- a/test/05_environment/environment.json +++ b/test/05_environment/environment.json @@ -1,3 +1,3 @@ { - "CIBW_ENVIRONMENT": "CIBW_TEST_VAR=\"a b c\" CIBW_TEST_VAR_2=1 PATH=$PATH:/opt/cibw_test_path" + "CIBW_ENVIRONMENT": "CIBW_TEST_VAR=\"a b c\" CIBW_TEST_VAR_2=1 CIBW_TEST_VAR_3=\"$(echo 'test string 3')\" PATH=$PATH:/opt/cibw_test_path " } diff --git a/unit_test/environment_test.py b/unit_test/environment_test.py index a1556c87..6b538a4d 100644 --- a/unit_test/environment_test.py +++ b/unit_test/environment_test.py @@ -34,6 +34,28 @@ def test_inheritance(): assert environment_dict == {'PATH': '/usr/bin:/usr/local/bin'} assert environment_cmds == ['export PATH=$PATH:/usr/local/bin'] +def test_shell_eval(): + environment_recipe = parse_environment('VAR="$(echo "a test" string)"') + + environment_dict = environment_recipe.as_dictionary( + prev_environment={} + ) + environment_cmds = environment_recipe.as_shell_commands() + + assert environment_dict == {'VAR': 'a test string'} + assert environment_cmds == ['export VAR="$(echo "a test" string)"'] + +def test_shell_eval_and_env(): + environment_recipe = parse_environment('VAR="$(echo "$PREV_VAR" string)"') + + environment_dict = environment_recipe.as_dictionary( + prev_environment={'PREV_VAR': '1 2 3'} + ) + environment_cmds = environment_recipe.as_shell_commands() + + assert environment_dict == {'PREV_VAR': '1 2 3', 'VAR': '1 2 3 string'} + assert environment_cmds == ['export VAR="$(echo "$PREV_VAR" string)"'] + def test_empty_var(): environment_recipe = parse_environment('CFLAGS=') From 7229ddeb6702844f3150cc1e77e08137e20219f9 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Sat, 2 Sep 2017 22:20:28 +0100 Subject: [PATCH 12/28] Windows uses a different pathsep so have to use that in the test --- test/05_environment/environment.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/05_environment/environment.json b/test/05_environment/environment.json index f7c9b46c..de967eb9 100644 --- a/test/05_environment/environment.json +++ b/test/05_environment/environment.json @@ -1,3 +1,4 @@ { - "CIBW_ENVIRONMENT": "CIBW_TEST_VAR=\"a b c\" CIBW_TEST_VAR_2=1 CIBW_TEST_VAR_3=\"$(echo 'test string 3')\" PATH=$PATH:/opt/cibw_test_path " + "CIBW_ENVIRONMENT": "CIBW_TEST_VAR=\"a b c\" CIBW_TEST_VAR_2=1 CIBW_TEST_VAR_3=\"$(echo 'test string 3')\" PATH=$PATH:/opt/cibw_test_path", + "CIBW_ENVIRONMENT_WINDOWS": "CIBW_TEST_VAR=\"a b c\" CIBW_TEST_VAR_2=1 CIBW_TEST_VAR_3=\"$(echo 'test string 3')\" PATH=$PATH;/opt/cibw_test_path" } From cc852f1cc15d449623af395c456301a142f411e4 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Sun, 3 Sep 2017 10:00:18 +0100 Subject: [PATCH 13/28] Ignore vscode project settings --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 62c1e736..5721e556 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,6 @@ ENV/ # Rope project settings .ropeproject + +# VSCode project settings +/.vscode From 60ed37000212f5bfc1d3ff5a8bdb8f800df8d0f4 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Sun, 3 Sep 2017 10:01:59 +0100 Subject: [PATCH 14/28] Semicolons need quoting --- test/05_environment/environment.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/05_environment/environment.json b/test/05_environment/environment.json index de967eb9..9ae227bd 100644 --- a/test/05_environment/environment.json +++ b/test/05_environment/environment.json @@ -1,4 +1,4 @@ { "CIBW_ENVIRONMENT": "CIBW_TEST_VAR=\"a b c\" CIBW_TEST_VAR_2=1 CIBW_TEST_VAR_3=\"$(echo 'test string 3')\" PATH=$PATH:/opt/cibw_test_path", - "CIBW_ENVIRONMENT_WINDOWS": "CIBW_TEST_VAR=\"a b c\" CIBW_TEST_VAR_2=1 CIBW_TEST_VAR_3=\"$(echo 'test string 3')\" PATH=$PATH;/opt/cibw_test_path" + "CIBW_ENVIRONMENT_WINDOWS": "CIBW_TEST_VAR=\"a b c\" CIBW_TEST_VAR_2=1 CIBW_TEST_VAR_3=\"$(echo 'test string 3')\" PATH=\"$PATH;/opt/cibw_test_path\"" } From a7c7a58407cacee22d4d41fd0a432cf086780510 Mon Sep 17 00:00:00 2001 From: Yannick Jadoul Date: Wed, 6 Sep 2017 15:07:58 +0200 Subject: [PATCH 15/28] Reducing code duplication in bin/run_test.py and bin/run_tests.py --- bin/run_test.py | 37 ++++++++++++++++++++----------------- bin/run_tests.py | 47 +++++++++++++++-------------------------------- 2 files changed, 35 insertions(+), 49 deletions(-) diff --git a/bin/run_test.py b/bin/run_test.py index 2fa1cbc5..95e76482 100755 --- a/bin/run_test.py +++ b/bin/run_test.py @@ -1,23 +1,10 @@ #!/usr/bin/python from __future__ import print_function -import os, sys, subprocess, shutil, json, argparse +import os, sys, subprocess, shutil, json from glob import glob -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("test_project_dir") - args = parser.parse_args() - - test_project = os.path.abspath(args.test_project_dir) - - # move cwd to the project root - os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - - if not os.path.exists(test_project): - print('No test project not found.', file=sys.stderr) - exit(2) - +def single_run(test_project): # load project settings into environment env_file = os.path.join(test_project, 'environment.json') project_env = {} @@ -40,6 +27,22 @@ def main(): # clean up shutil.rmtree('wheelhouse') - print('Project built successfully.') +if __name__ == '__main__': + import argparse -main() + parser = argparse.ArgumentParser() + parser.add_argument("test_project_dir") + args = parser.parse_args() + + project_path = os.path.abspath(args.test_project_dir) + + # move cwd to the project root + os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + if not os.path.exists(project_path): + print('No test project not found.', file=sys.stderr) + exit(2) + + single_run(project_path) + + print('Project built successfully.') diff --git a/bin/run_tests.py b/bin/run_tests.py index 2c8504af..98e5eb49 100755 --- a/bin/run_tests.py +++ b/bin/run_tests.py @@ -4,44 +4,27 @@ from __future__ import print_function import os, sys, subprocess, shutil, json from glob import glob -# move cwd to the project root -os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from . import run_test -### run the unit tests +if __name__ == '__main__': + # move cwd to the project root + os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -subprocess.check_call(['python', '-m', 'pytest', 'unit_test']) + ### run the unit tests -### run the integration tests + subprocess.check_call(['python', '-m', 'pytest', 'unit_test']) -test_projects = glob('test/??_*') + ### run the integration tests -if len(test_projects) == 0: - print('No test projects found. Aborting.', file=sys.stderr) - exit(2) + test_projects = glob('test/??_*') -print('Testing projects:', test_projects) + if len(test_projects) == 0: + print('No test projects found. Aborting.', file=sys.stderr) + exit(2) -for project_path in test_projects: - # load project settings into environment - env_file = os.path.join(project_path, 'environment.json') - project_env = {} - if os.path.exists(env_file): - with open(env_file) as f: - project_env = json.load(f) + print('Testing projects:', test_projects) - # run the build - env = os.environ.copy() - project_env = {str(k): str(v) for k, v in project_env.items()} # unicode not allowed in env - env.update(project_env) - print('Building %s with environment %s' % (project_path, project_env)) - subprocess.check_call(['cibuildwheel', project_path], env=env) - wheels = glob('wheelhouse/*.whl') - print('%s built successfully. %i wheels built.' % (project_path, len(wheels))) + for project_path in test_projects: + run_test.single_run(project_path) - # check some wheels were actually built - assert len(wheels) >= 4 - - # clean up - shutil.rmtree('wheelhouse') - -print('%d projects built successfully.' % len(test_projects)) + print('%d projects built successfully.' % len(test_projects)) From 0facf0ae1205a95cedc20cdb9e59a1b5e081a0c2 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 6 Sep 2017 14:22:44 +0100 Subject: [PATCH 16/28] Update README.md Add Yannick to collaborators --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5e6eb012..65b3de49 100644 --- a/README.md +++ b/README.md @@ -307,6 +307,7 @@ Maintainers - Joe Rickerby [@joerick](https://github.com/joerick) - Tomas Garcia [@tgarc](https://github.com/tgarc) +- Yannick Jadoul [@YannickJadoul](https://github.com/YannickJadoul) Credits ------- From 3188c978c9c964a89ebb860e34595d4e06d36bc0 Mon Sep 17 00:00:00 2001 From: Yannick Jadoul Date: Wed, 6 Sep 2017 15:46:31 +0200 Subject: [PATCH 17/28] Fixing 'ValueError: Attempted relative import in non-package' --- bin/run_tests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/run_tests.py b/bin/run_tests.py index 98e5eb49..59ac48d7 100755 --- a/bin/run_tests.py +++ b/bin/run_tests.py @@ -4,7 +4,7 @@ from __future__ import print_function import os, sys, subprocess, shutil, json from glob import glob -from . import run_test +import run_test if __name__ == '__main__': # move cwd to the project root From 93d2a593c4f6e71f3588ca6856346decc0e1e058 Mon Sep 17 00:00:00 2001 From: Yannick Jadoul Date: Wed, 6 Sep 2017 16:14:39 +0200 Subject: [PATCH 18/28] Making call from run_tests to run_test subprocess.check_call --- bin/run_test.py | 5 +---- bin/run_tests.py | 5 ++--- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/bin/run_test.py b/bin/run_test.py index 95e76482..90f0e35b 100755 --- a/bin/run_test.py +++ b/bin/run_test.py @@ -35,10 +35,7 @@ if __name__ == '__main__': args = parser.parse_args() project_path = os.path.abspath(args.test_project_dir) - - # move cwd to the project root - os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - + if not os.path.exists(project_path): print('No test project not found.', file=sys.stderr) exit(2) diff --git a/bin/run_tests.py b/bin/run_tests.py index 59ac48d7..9264e2be 100755 --- a/bin/run_tests.py +++ b/bin/run_tests.py @@ -4,8 +4,6 @@ from __future__ import print_function import os, sys, subprocess, shutil, json from glob import glob -import run_test - if __name__ == '__main__': # move cwd to the project root os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -24,7 +22,8 @@ if __name__ == '__main__': print('Testing projects:', test_projects) + run_test_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'run_test.py') for project_path in test_projects: - run_test.single_run(project_path) + subprocess.check_call([sys.executable, run_test_path, project_path]) print('%d projects built successfully.' % len(test_projects)) From be474fd7f7400dceebf2352576110fde7154f752 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 6 Sep 2017 18:27:06 +0100 Subject: [PATCH 19/28] Fix string splitting for the env string --- cibuildwheel/environment.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/cibuildwheel/environment.py b/cibuildwheel/environment.py index 249dcc3a..1e236d67 100644 --- a/cibuildwheel/environment.py +++ b/cibuildwheel/environment.py @@ -23,8 +23,19 @@ def split_env_items(env_string): ['VAR="a string"', 'THING=\\'single "quotes"\\''] >>> split_env_items('VAR="dont \\\\"forget\\\\" about backslashes"') ['VAR="dont \\\\"forget\\\\" about backslashes"'] + >>> split_env_items('PATH="$PATH;/opt/cibw_test_path"') + ['PATH="$PATH;/opt/cibw_test_path"'] + >>> split_env_items('PATH2="something with spaces"') + ['PATH2="something with spaces"'] ''' - return list(bashlex.split(env_string)) + command_node = bashlex.parsesingle(env_string) + result = [] + + for word_node in command_node.parts: + part_string = env_string[word_node.pos[0]:word_node.pos[1]] + result.append(part_string) + + return result class EnvironmentAssignment(object): From 34e8ee0f25c5898005212819176057c2084fa062 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 6 Sep 2017 18:35:42 +0100 Subject: [PATCH 20/28] Fix empty env string case --- cibuildwheel/environment.py | 3 +++ unit_test/environment_test.py | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/cibuildwheel/environment.py b/cibuildwheel/environment.py index 1e236d67..1b2adec4 100644 --- a/cibuildwheel/environment.py +++ b/cibuildwheel/environment.py @@ -28,6 +28,9 @@ def split_env_items(env_string): >>> split_env_items('PATH2="something with spaces"') ['PATH2="something with spaces"'] ''' + if not env_string: + return [] + command_node = bashlex.parsesingle(env_string) result = [] diff --git a/unit_test/environment_test.py b/unit_test/environment_test.py index 6b538a4d..0f0f385e 100644 --- a/unit_test/environment_test.py +++ b/unit_test/environment_test.py @@ -66,3 +66,19 @@ def test_empty_var(): assert environment_dict == {'CFLAGS': ''} assert environment_cmds == ['export CFLAGS='] + +def test_no_vars(): + environment_recipe = parse_environment('') + + environment_dict = environment_recipe.as_dictionary(prev_environment={}) + environment_cmds = environment_recipe.as_shell_commands() + + assert environment_dict == {} + assert environment_cmds == [] + +def test_no_vars_pass_through(): + environment_recipe = parse_environment('') + + environment_dict = environment_recipe.as_dictionary(prev_environment={'CIBUILDWHEEL': 'awesome'}) + + assert environment_dict == {'CIBUILDWHEEL': 'awesome'} From 7c416329d42a6f1188b2c92612f863b327f227f2 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 6 Sep 2017 19:14:06 +0100 Subject: [PATCH 21/28] Add CIBW_ENVIRONMENT to README and reorder options --- README.md | 51 +++++++++++++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 5e6eb012..50c30e3d 100644 --- a/README.md +++ b/README.md @@ -113,24 +113,43 @@ Default: `auto` For `linux` you need Docker running, on Mac or Linux. For `macos`, you need a Mac machine, and note that this script is going to automatically install MacPython on your system, so don't run on your development machine. For `windows`, you need to run in Windows, and it will build and test for all versions of Python at `C:\PythonXX[-x64]`. -| Environment variable: `CIBW_TEST_COMMAND` +| Environment variable: `CIBW_SKIP` | --- Optional. -Shell command to run the tests. The project root should be included in the command as "{project}". The wheel will be installed automatically and available for import from the tests. +Space-separated list of builds to skip. Each build has an identifier like `cp27-manylinux1_x86_64` or `cp34-macosx_10_6_intel` - you can list ones to skip here and `cibuildwheel` won't try to build them. -Example: `nosetests {project}/tests` +The format is `python_tag-platform_tag`. The tags are as defined in [PEP 0425](https://www.python.org/dev/peps/pep-0425/#details). -| Environment variable: `CIBW_TEST_REQUIRES` +Python tags look like `cp27` `cp34` `cp35` `cp36` + +Platform tags look like `macosx_10_6_intel` `manylinux1_x86_64` `manylinux1_i386` `win32` `win_amd64` + +You can also use shell-style globbing syntax (as per `fnmatch`) + +Example: `cp27-macosx_10_6_intel` (don't build on Python 2 on Mac) +Example: `cp27-win*` (don't build on Python 2.7 on Windows) +Example: `cp34-* cp35-*` (don't build on Python 3.4 or Python 3.5) + +| Environment variable: `CIBW_ENVIRONMENT` | --- Optional. -Space-separated list of dependencies required for running the tests. +A space-separated list of environment variables to set during the build. Bash syntax should be used (even on Windows!). -Example: `pytest` -Example: `nose==1.3.7 moto==0.4.31` +You must set this variable to pass variables to Linux builds (since they execute in a Docker container). It also works for the other platforms. + +You can use `$PATH` syntax to insert other variables, or the `$(pwd)` syntax to insert the output of other shell commands. + +Example: `CFLAGS="-g -Wall" CXXFLAGS="-Wall"` +Example: `PATH=$PATH:/usr/local/bin` +Example: `BUILD_TIME="$(date)"` +Example: `PIP_EXTRA_INDEX_URL="https://pypi.myorg.com/simple"` + +Platform-specific variants also available: +`CIBW_ENVIRONMENT_MACOS` | `CIBW_ENVIRONMENT_WINDOWS` | `CIBW_ENVIRONMENT_LINUX` | Environment variable: `CIBW_BEFORE_BUILD` | --- @@ -149,24 +168,24 @@ Example: `{pip} install pybind11` Platform-specific variants also available: `CIBW_BEFORE_BUILD_MACOS` | `CIBW_BEFORE_BUILD_WINDOWS` | `CIBW_BEFORE_BUILD_LINUX` -| Environment variable: `CIBW_SKIP` +| Environment variable: `CIBW_TEST_COMMAND` | --- Optional. -Space-separated list of builds to skip. Each build has an identifier like `cp27-manylinux1_x86_64` or `cp34-macosx_10_6_intel` - you can list ones to skip here and `cibuildwheel` won't try to build them. +Shell command to run tests after the build. The wheel will be installed automatically and available for import from the tests. The project root should be included in the command as "{project}". -The format is `python_tag-platform_tag`. The tags are as defined in [PEP 0425](https://www.python.org/dev/peps/pep-0425/#details). +Example: `nosetests {project}/tests` -Python tags look like `cp27` `cp34` `cp35` `cp36` +| Environment variable: `CIBW_TEST_REQUIRES` +| --- -Platform tags look like `macosx_10_6_intel` `manylinux1_x86_64` `manylinux1_i386` `win32` `win_amd64` +Optional. -You can also use shell-style globbing syntax (as per `fnmatch`) +Space-separated list of dependencies required for running the tests. -Example: `cp27-macosx_10_6_intel ` (don't build on Python 2 on Mac) -Example: `cp27-win*` (don't build on Python 2.7 on Windows) -Example: `cp34-* cp35-*` (don't build on Python 3.4 or Python 3.5) +Example: `pytest` +Example: `nose==1.3.7 moto==0.4.31` -- From 523dfc06434c67d33c22ab76033405353689c994 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 6 Sep 2017 19:18:28 +0100 Subject: [PATCH 22/28] Fix some linter warnings --- cibuildwheel/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cibuildwheel/__main__.py b/cibuildwheel/__main__.py index c6c8d06f..1edc9505 100644 --- a/cibuildwheel/__main__.py +++ b/cibuildwheel/__main__.py @@ -1,5 +1,5 @@ from __future__ import print_function -import argparse, os, subprocess, sys, textwrap, shlex +import argparse, os, subprocess, sys, textwrap import cibuildwheel import cibuildwheel.linux, cibuildwheel.windows, cibuildwheel.macos @@ -78,7 +78,7 @@ def main(): try: environment = parse_environment(environment_config) except (EnvironmentParseError, ValueError) as e: - print('cibuildwheel: Malformed environment option "%s"' % key_value, file=sys.stderr) + print('cibuildwheel: Malformed environment option "%s"' % environment_config, file=sys.stderr) import traceback traceback.print_exc(None, sys.stderr) exit(2) From bade2da061815771e44a01bb2242a08841469bba Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 6 Sep 2017 22:02:10 +0100 Subject: [PATCH 23/28] Add test for before_build executing in a shell --- test/03_before_build/environment.json | 4 ++-- test/03_before_build/setup.py | 18 ++++++++++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/test/03_before_build/environment.json b/test/03_before_build/environment.json index 04db7726..75074513 100644 --- a/test/03_before_build/environment.json +++ b/test/03_before_build/environment.json @@ -1,4 +1,4 @@ { - "CIBW_BEFORE_BUILD": "{python} -c \"import sys; open('/tmp/pythonversion.txt', 'w').write(sys.version)\"", - "CIBW_BEFORE_BUILD_WINDOWS": "{python} -c \"import sys; open('c:\\pythonversion.txt', 'w').write(sys.version)\"" + "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)\"" } diff --git a/test/03_before_build/setup.py b/test/03_before_build/setup.py index 0d4d138a..8f6fbcf4 100644 --- a/test/03_before_build/setup.py +++ b/test/03_before_build/setup.py @@ -1,8 +1,8 @@ from setuptools import setup, Extension -import sys +import sys, os if sys.argv[-1] != '--name': - # assert that the Python version as written to version.txt in the CIBW_BEFORE_BUILD step + # assert that the Python version as written to pythonversion.txt in the CIBW_BEFORE_BUILD step # is the same one as is currently running. version_file = 'c:\\pythonversion.txt' if sys.platform == 'win32' else '/tmp/pythonversion.txt' with open(version_file) as f: @@ -11,6 +11,20 @@ if sys.argv[-1] != '--name': print('sys.version', sys.version) assert stored_version == sys.version + # check that the executable also was written + executable_file = 'c:\\pythonexecutable.txt' if sys.platform == 'win32' else '/tmp/pythonexecutable.txt' + with open(executable_file) as f: + stored_executable = f.read() + + stored_executable_real = os.path.realpath(stored_executable) + sys_executable_real = os.path.realpath(sys.executable) + + print('stored_executable_real', stored_executable_real) + print('sys_executable_real', sys_executable_real) + assert stored_executable_real == sys_executable_real + + print('PATH', os.environ['PATH']) + setup( name="spam", ext_modules=[Extension('spam', sources=['spam.c'])], From ddb9c077a5bd2cecc1bec64128192ef043a56c53 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 6 Sep 2017 22:07:24 +0100 Subject: [PATCH 24/28] Use os.path.samefile to get around filename case insensitivity --- test/03_before_build/setup.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/test/03_before_build/setup.py b/test/03_before_build/setup.py index 8f6fbcf4..4baa5634 100644 --- a/test/03_before_build/setup.py +++ b/test/03_before_build/setup.py @@ -15,15 +15,9 @@ if sys.argv[-1] != '--name': executable_file = 'c:\\pythonexecutable.txt' if sys.platform == 'win32' else '/tmp/pythonexecutable.txt' with open(executable_file) as f: stored_executable = f.read() - - stored_executable_real = os.path.realpath(stored_executable) - sys_executable_real = os.path.realpath(sys.executable) - - print('stored_executable_real', stored_executable_real) - print('sys_executable_real', sys_executable_real) - assert stored_executable_real == sys_executable_real - - print('PATH', os.environ['PATH']) + print('stored_executable', stored_executable) + print('sys.executable', sys.executable) + assert os.path.samefile(stored_executable, sys.executable) setup( name="spam", From b64a1c14659f94cd7d2ff5dc6a548a790eca14db Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 6 Sep 2017 22:11:05 +0100 Subject: [PATCH 25/28] Add shell-style execution on the Mac --- cibuildwheel/macos.py | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/cibuildwheel/macos.py b/cibuildwheel/macos.py index d98b9731..20cad75b 100644 --- a/cibuildwheel/macos.py +++ b/cibuildwheel/macos.py @@ -19,10 +19,14 @@ def build(project_dir, package_name, output_dir, test_command, test_requires, be PythonConfiguration(version='3.6', identifier='cp36-macosx_10_6_intel', url='https://www.python.org/ftp/python/3.6.0/python-3.6.0-macosx10.6.pkg'), ] - def shell(args, env=None, cwd=None): + def call(args, env=None, cwd=None, shell=False): # print the command executing for the logs - print('+ ' + ' '.join(shlex_quote(a) for a in args)) - return subprocess.check_call(args, env=env, cwd=cwd) + if shell: + print('+ %s' % args) + else: + print('+ ' + ' '.join(shlex_quote(a) for a in args)) + + return subprocess.check_call(args, env=env, cwd=cwd, shell=shell) for config in python_configurations: if skip(config.identifier): @@ -30,9 +34,9 @@ def build(project_dir, package_name, output_dir, test_command, test_requires, be continue # download the pkg - shell(['curl', '-L', '-o', '/tmp/Python.pkg', config.url]) + call(['curl', '-L', '-o', '/tmp/Python.pkg', config.url]) # install - shell(['sudo', 'installer', '-pkg', '/tmp/Python.pkg', '-target', '/']) + call(['sudo', 'installer', '-pkg', '/tmp/Python.pkg', '-target', '/']) env = os.environ.copy() env['PATH'] = os.pathsep.join([ @@ -44,14 +48,14 @@ def build(project_dir, package_name, output_dir, test_command, test_requires, be pip = 'pip3' if config.version[0] == '3' else 'pip2' # check what version we're on - shell(['which', python], env=env) - shell([python, '--version'], env=env) + call(['which', python], env=env) + call([python, '--version'], env=env) # install pip & wheel - shell([python, '-m', 'ensurepip', '--upgrade'], env=env) - shell([pip, '--version'], env=env) - shell([pip, 'install', 'wheel'], env=env) - shell([pip, 'install', 'delocate'], env=env) + call([python, '-m', 'ensurepip', '--upgrade'], env=env) + call([pip, '--version'], env=env) + call([pip, 'install', 'wheel'], env=env) + call([pip, 'install', 'delocate'], env=env) # setup dirs if os.path.exists('/tmp/built_wheel'): @@ -64,10 +68,10 @@ def build(project_dir, package_name, output_dir, test_command, test_requires, be # run the before_build command if before_build: before_build_prepared = prepare_command(before_build, python=python, pip=pip) - shell(shlex.split(before_build_prepared), env=env) + call(before_build_prepared, env=env, shell=True) # build the wheel - shell([pip, 'wheel', project_dir, '-w', '/tmp/built_wheel', '--no-deps'], env=env) + call([pip, 'wheel', project_dir, '-w', '/tmp/built_wheel', '--no-deps'], env=env) built_wheel = glob('/tmp/built_wheel/*.whl')[0] if built_wheel.endswith('none-any.whl'): @@ -75,24 +79,24 @@ def build(project_dir, package_name, output_dir, test_command, test_requires, be shutil.move(built_wheel, '/tmp/delocated_wheel') else: # list the dependencies - shell(['delocate-listdeps', built_wheel], env=env) + call(['delocate-listdeps', built_wheel], env=env) # rebuild the wheel with shared libraries included and place in output dir - shell(['delocate-wheel', '-w', '/tmp/delocated_wheel', built_wheel], env=env) + call(['delocate-wheel', '-w', '/tmp/delocated_wheel', built_wheel], env=env) delocated_wheel = glob('/tmp/delocated_wheel/*.whl')[0] # install the wheel - shell([pip, 'install', delocated_wheel], env=env) + call([pip, 'install', delocated_wheel], env=env) # test the wheel if test_requires: - shell([pip, 'install'] + test_requires, env=env) + call([pip, 'install'] + test_requires, env=env) if test_command: # run the tests from $HOME, with an absolute path in the command # (this ensures that Python runs the tests against the installed wheel # and not the repo code) abs_project_dir = os.path.abspath(project_dir) test_command_absolute = test_command.format(project=abs_project_dir) - shell(shlex.split(test_command_absolute), cwd=os.environ['HOME'], env=env) + call(shlex.split(test_command_absolute), cwd=os.environ['HOME'], env=env) # we're all done here; move it to output shutil.move(delocated_wheel, output_dir) From aca294f5bb8c28ce96f30440d29575be8cb1cd37 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Wed, 6 Sep 2017 22:33:01 +0100 Subject: [PATCH 26/28] os.path.samefile is not available on windows. --- test/03_before_build/setup.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/03_before_build/setup.py b/test/03_before_build/setup.py index 4baa5634..333de6e8 100644 --- a/test/03_before_build/setup.py +++ b/test/03_before_build/setup.py @@ -17,7 +17,8 @@ if sys.argv[-1] != '--name': stored_executable = f.read() print('stored_executable', stored_executable) print('sys.executable', sys.executable) - assert os.path.samefile(stored_executable, sys.executable) + # windows/mac are case insensitive + assert os.path.realpath(stored_executable).lower() == os.path.realpath(sys.executable).lower() setup( name="spam", From d381b63b0010f1f7b89e7603f4b9b3becde8edb0 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Thu, 7 Sep 2017 18:21:01 +0100 Subject: [PATCH 27/28] Update README --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 160bc244..d2295f4c 100644 --- a/README.md +++ b/README.md @@ -137,14 +137,15 @@ Example: `nose==1.3.7 moto==0.4.31` Optional. -Shell command to run before building the wheel. This option allows you to run a command in **each** Python environment before the `pip wheel` command. This is useful if you need to set up some dependency so it's available during the build. +A shell command to run before building the wheel. This option allows you to run a command in **each** Python environment before the `pip wheel` command. This is useful if you need to set up some dependency so it's available during the build. If dependencies are required to build your wheel (for example if you include a header from a Python module), set this to `{pip} install .`, and the dependencies will be installed automatically by pip. However, this means your package will be built twice - if your package takes a long time to build, you might wish to manually list the dependencies here instead. The active Python binary can be accessed using `{python}`, and pip with `{pip}`. These are useful when you need to write `python3` or `pip3` on a Python 3.x build. Example: `{pip} install .` -Example: `{pip} install pybind11` +Example: `{pip} install pybind11` +Example: `yum install -y libffi-dev && {pip} install .` Platform-specific variants also available: `CIBW_BEFORE_BUILD_MACOS` | `CIBW_BEFORE_BUILD_WINDOWS` | `CIBW_BEFORE_BUILD_LINUX` From d0a211dcf5d09b9cd9d9480a5b341a0e2219ceb1 Mon Sep 17 00:00:00 2001 From: Joe Rickerby Date: Thu, 7 Sep 2017 18:35:21 +0100 Subject: [PATCH 28/28] Update README to combine environment sections --- README.md | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index ce655dcb..a5462afc 100644 --- a/README.md +++ b/README.md @@ -31,17 +31,6 @@ Usage `cibuildwheel` is not intended to run on your development machine. It will try to install packages globally; this is no good. Travis CI and Appveyor run their builds in isolated environments, so are ideal for this kind of script. -`cibuildwheel` defines the environment variable `CIBUILDWHEEL` to the value `1` allowing projects for which the C extension is optional to make it mandatory when building wheels. -An easy way to do it in Python 3 is through the `optional` named argument of `Extension` constructor in your `setup.py`: -```python -myextension = Extension( - "myextension", - ["myextension.c"], - optional=os.environ.get('CIBUILDWHEEL', '0') != '1', -) -``` - - ### Minimal setup - Create a `.travis.yml` file in your repo. @@ -162,6 +151,8 @@ Example: `PIP_EXTRA_INDEX_URL="https://pypi.myorg.com/simple"` Platform-specific variants also available: `CIBW_ENVIRONMENT_MACOS` | `CIBW_ENVIRONMENT_WINDOWS` | `CIBW_ENVIRONMENT_LINUX` +In addition to the above, `cibuildwheel` always defines the environment variable `CIBUILDWHEEL=1`. This can be useful for [building wheels with optional extensions](https://github.com/joerick/cibuildwheel/wiki/Building-packages-with-optional-C-extensions). + | Environment variable: `CIBW_BEFORE_BUILD` | ---