Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support qemu in GitHub Actions #482

Merged
merged 28 commits into from
Jan 1, 2021
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
76fe472
Support docker qemu emulation in any linux env
asfaltboy Dec 12, 2020
90a14b9
fixup! Support docker qemu emulation in any linux env
asfaltboy Dec 12, 2020
ec99c01
Allow architecture selection
asfaltboy Dec 16, 2020
0d29bf8
refactor: --archs, CIBW_ARCHS, "auto"
henryiii Dec 18, 2020
26b7b72
fix: drop printout
henryiii Dec 18, 2020
0c9af03
Add platform-specific CIBW_ARCHS options.
joerick Dec 20, 2020
f73b608
Add unit test for the option parsing
joerick Dec 21, 2020
7c86223
Add platform-specific arch option unit test
joerick Dec 21, 2020
5aa85e8
Add integration test for emulation
joerick Dec 21, 2020
0ca294e
Add separate runner for emulation tests
joerick Dec 21, 2020
91800e4
Update .github/workflows/test.yml
joerick Dec 21, 2020
27f1a4e
Use 'latest' for CI
joerick Dec 21, 2020
8a1a0a4
Add asserts on macOS/Windows so that they can't be anything but default
joerick Dec 21, 2020
b34bdee
Fix native archs expansion for windows
joerick Dec 21, 2020
eb03a11
Rename Architecture to match value amd64 -> AMD64
joerick Dec 23, 2020
3696f15
Add example github config
joerick Dec 23, 2020
68dc286
Add docs for ARCHS option
joerick Dec 23, 2020
3983fb2
Fix broken link in docs
joerick Dec 23, 2020
460eaa7
Add mini-guide to the FAQ section
joerick Dec 23, 2020
f6ce1c9
Update example config
joerick Dec 23, 2020
6b00931
Allow the testing of any of the example configs in /examples
joerick Dec 23, 2020
0893731
Simplify arch matching code
joerick Dec 28, 2020
b5f5437
Suggestion from review
joerick Dec 31, 2020
5ee663f
Add test for failure when setting CIBW_ARCHS on macos/windows
joerick Dec 31, 2020
f434072
option ordering
joerick Dec 31, 2020
d65da2e
docs edits from review
joerick Dec 31, 2020
5b5078e
Copy edit of --archs help text to make 'auto' clearer
joerick Dec 31, 2020
aa87128
Make the StrEnums proper Enums
joerick Dec 31, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-18.04, windows-latest, macos-latest]
os: [ubuntu-latest, windows-latest, macos-latest]
python_version: ['3.7']
timeout-minutes: 180
steps:
Expand Down Expand Up @@ -55,3 +55,24 @@ jobs:
- name: Test cibuildwheel
run: |
python ./bin/run_tests.py

test-emulated:
name: Test emulated cibuildwheel using qemu
runs-on: ubuntu-latest
timeout-minutes: 180
YannickJadoul marked this conversation as resolved.
Show resolved Hide resolved
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- name: Install dependencies
run: |
python -m pip install -r requirements-dev.txt

- name: Set up QEMU
id: qemu
uses: docker/setup-qemu-action@v1
with:
platforms: all

- name: Run the emulation tests
run: |
pytest --run-emulation test/test_emulation.py
51 changes: 36 additions & 15 deletions bin/run_example_ci_configs.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import textwrap
import time
import click
from glob import glob
from collections import namedtuple
from urllib.parse import quote

Expand All @@ -30,49 +31,67 @@ def generate_basic_project(path):
project.generate(path)


CIService = namedtuple('CIService', 'name src_config_path dst_config_path badge_md')
CIService = namedtuple('CIService', 'name dst_config_path badge_md')
services = [
CIService(
name='appveyor',
src_config_path='examples/appveyor-minimal.yml',
dst_config_path='appveyor.yml',
badge_md='[![Build status](https://ci.appveyor.com/api/projects/status/wbsgxshp05tt1tif/branch/{branch}?svg=true)](https://ci.appveyor.com/project/joerick/cibuildwheel/branch/{branch})',
),
CIService(
name='azure-pipelines',
src_config_path='examples/azure-pipelines-minimal.yml',
dst_config_path='azure-pipelines.yml',
badge_md='[![Build Status](https://dev.azure.com/joerick0429/cibuildwheel/_apis/build/status/joerick.cibuildwheel?branchName={branch})](https://dev.azure.com/joerick0429/cibuildwheel/_build/latest?definitionId=2&branchName={branch})',
),
CIService(
name='circle-ci',
src_config_path='examples/circleci-minimal.yml',
name='circleci',
dst_config_path='.circleci/config.yml',
badge_md='[![CircleCI](https://circleci.com/gh/joerick/cibuildwheel/tree/{branch_escaped}.svg?style=svg)](https://circleci.com/gh/joerick/cibuildwheel/tree/{branch})',
),
CIService(
name='github',
src_config_path='examples/github-minimal.yml',
dst_config_path='.github/workflows/example.yml',
badge_md='[![Build](https://github.com/joerick/cibuildwheel/workflows/Build/badge.svg?branch={branch})](https://github.com/joerick/cibuildwheel/actions)',
),
CIService(
name='travis-ci',
src_config_path='examples/travis-ci-minimal.yml',
dst_config_path='.travis.yml',
badge_md='[![Build Status](https://travis-ci.org/joerick/cibuildwheel.svg?branch={branch})](https://travis-ci.org/joerick/cibuildwheel)',
),
CIService(
name='gitlab',
src_config_path='examples/gitlab-minimal.yml',
dst_config_path='.gitlab-ci.yml',
badge_md='[![Gitlab](https://gitlab.com/joerick/cibuildwheel/badges/{branch}/pipeline.svg)](https://gitlab.com/joerick/cibuildwheel/-/commits/{branch})'
),
]


def ci_service_for_config_file(config_file):
for service in services:
if Path(config_file).name.startswith(service.name+'-'):
joerick marked this conversation as resolved.
Show resolved Hide resolved
return service
raise ValueError(f'unknown ci service for config file {config_file}')


@click.command()
def run_example_ci_configs():
@click.argument('config_files', nargs=-1, type=click.Path())
def run_example_ci_configs(config_files=None):
'''
Test the example configs. If no files are specified, will test
examples/*-minimal.yml
'''

if len(config_files) == 0:
config_files = glob('examples/*-minimal.yml')

# check each CI service has at most 1 config file
configs_by_service = {}
for config_file in config_files:
service = ci_service_for_config_file(config_file)
if service.name in configs_by_service:
raise Exception('You cannot specify more than one config per CI service')
configs_by_service[service.name] = config_file

if git_repo_has_changes():
print('Your git repo has uncommitted changes. Commit or stash before continuing.')
exit(1)
Expand All @@ -91,18 +110,19 @@ def run_example_ci_configs():
example_project = Path('example_root')
generate_basic_project(example_project)

for service in services:
src_config_file = Path(service.src_config_path)
for config_file in config_files:
service = ci_service_for_config_file(config_file)
src_config_file = Path(config_file)
dst_config_file = example_project / service.dst_config_path

dst_config_file.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(src_config_file, dst_config_file)

run(['git', 'add', example_project], check=True)
run(['git', 'commit', '-m', textwrap.dedent(f'''
run(['git', 'commit', '--no-verify', '-m', textwrap.dedent(f'''
Test example minimal configs

Testing files: {[s.src_config_path for s in services]}
Testing files: {config_files}
Generated from branch: {previous_branch}
Time: {timestamp}
''')], check=True)
Expand All @@ -116,9 +136,10 @@ def run_example_ci_configs():
print('> ')
print('> | Service | Config | Status |')
print('> |---|---|---|')
for service in services:
for config_file in config_files:
service = ci_service_for_config_file(config_file)
badge = service.badge_md.format(branch=branch_name, branch_escaped=quote(branch_name, safe=''))
print(f'> | {service.name} | `{service.src_config_path}` | {badge} |')
print(f'> | {service.name} | `{config_file}` | {badge} |')
print('> ')
print('> Generated by `bin/run_example_ci_config.py`')
print()
Expand Down
29 changes: 26 additions & 3 deletions cibuildwheel/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
parse_environment,
)
from cibuildwheel.util import (
Architecture,
BuildOptions,
BuildSelector,
DependencyConstraints,
Expand Down Expand Up @@ -67,6 +68,19 @@ def main() -> None:
run in Windows, and it will build and test for all versions of
Python. Default: auto.
''')

parser.add_argument(
'--archs',
default=None,
help='''
Comma-separated list of CPU architectures to build for.
If unspecified, builds the architectures natively supported
on this machine. Set this option to build an architecture
via emulation, for example, using binfmt_misc and qemu.
Default: auto
henryiii marked this conversation as resolved.
Show resolved Hide resolved
Choices: auto, {}
asfaltboy marked this conversation as resolved.
Show resolved Hide resolved
'''.format(", ".join(a.name for a in Architecture)),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going from top to bottom, so I haven't seen yet what this is, but a.name for a in Architecture looks weird. Architecture tells me it's a type, and you're looping over a type?

(I'll figure out what's going on soon enough, but this is my first reaction ;-) )

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As expected, an enum.

That's horrible syntax IMO, looping over a type, but apparently how Python envisions you doing things, so why not, then.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a.value rather than a.name, though, to match the logic from Architecture.parse_config() (even though, yes, names and values are the same) ?

Copy link
Contributor

@henryiii henryiii Dec 30, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's not a "type", it's an enum. How else would you loop over all the possible enums? C++ doesn't have this capability (yet, at least, until reflection is available?). Keep in mind adding any methods to Enum would remove possible Enum values! And yes, I think it should be name.value.

It acts like a container of enum instances, so looping over it is pretty reasonable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, an enum is a type, no? Both in the broad sense (we're using it to tell mypy what's passed) as well as in the Python sense where enum.EnumMeta is a subclass of type.
But yeah, I can see it's a special one. Just haven't seen this that often and the capital/syntax highlighting is quite confusing; nothing this PR should do anything about, though!

Reiterating that a.name -> a.value is still a change to be made, just to make sure the serious part of my comments stands out ;-)

Copy link
Contributor

@henryiii henryiii Dec 30, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at the code, CIProvider really should not be a StrEnum - there's no need for that. I'm rather thinking Architecture shouldn't be either. It is not needed to subclass str to use strings as values, but only adds implicit conversion. I don't see any reason that we can't use explicit conversion here.

Though the implicit form does allow this beauty:

", ".join(Architecture),

How's that for a "type"? :)

BTW, an enum should be thought of as both a type and a container of all the instances of that type. The fact that is is a type is actually a bit of a workaround in leu of true dedicated language support, for example Architecture.count is the str.count method; you can't add a "count" member to a StrEnum (at least without breaking its stringiness).

@joerick what do you think? Should I remove str, from both of these and make them regular enums with string-based values, or do you want me to add:

# Identical to StrEnum in Python 3.10 except for less checking on construction
# that we really are providing strings, and no support for enum.auto().
class StrEnum(str, Enum):
    __str__ = str.__str__

and then use that, making these full StrEnums? Type safety/explicit or shorter/implicit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How's that for a "type"? :)

Hah, still horrible, but better than before? ;-)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm thinking the code might actually be clearer if we used plain old enums, rather than the StrEnum thing. Now we have type-checking, we end up having to be strict with them anyway. And enum.value is clearer and more explicit.

)
parser.add_argument('--output-dir',
default=os.environ.get('CIBW_OUTPUT_DIR', 'wheelhouse'),
help='Destination folder for the wheels.')
Expand Down Expand Up @@ -168,8 +182,14 @@ def main() -> None:
print('cibuildwheel: Could not find setup.py, setup.cfg or pyproject.toml at root of package', file=sys.stderr)
exit(2)

if args.archs is not None:
archs_config_str = args.archs
else:
archs_config_str = get_option_from_environment('CIBW_ARCHS', platform=platform, default='auto')
archs = Architecture.parse_config(archs_config_str, platform=platform)

if args.print_build_identifiers:
print_build_identifiers(platform, build_selector)
print_build_identifiers(platform, build_selector, archs)
exit(0)

manylinux_images: Optional[Dict[str, str]] = None
Expand Down Expand Up @@ -202,6 +222,7 @@ def main() -> None:
manylinux_images[build_platform] = image

build_options = BuildOptions(
architectures=archs,
package_dir=package_dir,
output_dir=output_dir,
test_command=test_command,
Expand Down Expand Up @@ -284,10 +305,12 @@ def print_preamble(platform: str, build_options: BuildOptions) -> None:
print('\nHere we go!\n')


def print_build_identifiers(platform: str, build_selector: BuildSelector) -> None:
def print_build_identifiers(
platform: str, build_selector: BuildSelector, architectures: List[Architecture]
) -> None:
python_configurations: List[Any] = []
if platform == 'linux':
python_configurations = cibuildwheel.linux.get_python_configurations(build_selector)
python_configurations = cibuildwheel.linux.get_python_configurations(build_selector, architectures)
elif platform == 'windows':
python_configurations = cibuildwheel.windows.get_python_configurations(build_selector)
elif platform == 'macos':
Expand Down
44 changes: 16 additions & 28 deletions cibuildwheel/linux.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import platform
import subprocess
import sys
import textwrap
Expand All @@ -8,29 +7,10 @@

from .docker_container import DockerContainer
from .logger import log
from .util import (BuildOptions, BuildSelector, NonPlatformWheelError,
get_build_verbosity_extra_flags, prepare_command)


def matches_platform(identifier: str) -> bool:
pm = platform.machine()
if pm == "x86_64":
# x86_64 machines can run i686 docker containers
if identifier.endswith('x86_64') or identifier.endswith('i686'):
return True
elif pm == "i686":
if identifier.endswith('i686'):
return True
elif pm == "aarch64":
if identifier.endswith('aarch64'):
return True
elif pm == "ppc64le":
if identifier.endswith('ppc64le'):
return True
elif pm == "s390x":
if identifier.endswith('s390x'):
return True
return False
from .util import (
Architecture, BuildOptions, BuildSelector, NonPlatformWheelError,
get_build_verbosity_extra_flags, prepare_command,
)


class PythonConfiguration(NamedTuple):
Expand All @@ -43,7 +23,9 @@ def path(self):
return PurePath(self.path_str)


def get_python_configurations(build_selector: BuildSelector) -> List[PythonConfiguration]:
def get_python_configurations(
build_selector: BuildSelector, architectures: List[Architecture]
) -> List[PythonConfiguration]:
python_configurations = [
PythonConfiguration(version='2.7', identifier='cp27-manylinux_x86_64', path_str='/opt/python/cp27-cp27m'),
PythonConfiguration(version='2.7', identifier='cp27-manylinux_x86_64', path_str='/opt/python/cp27-cp27mu'),
Expand Down Expand Up @@ -78,8 +60,14 @@ def get_python_configurations(build_selector: BuildSelector) -> List[PythonConfi
PythonConfiguration(version='3.8', identifier='cp38-manylinux_s390x', path_str='/opt/python/cp38-cp38'),
PythonConfiguration(version='3.9', identifier='cp39-manylinux_s390x', path_str='/opt/python/cp39-cp39'),
]
# skip builds as required
return [c for c in python_configurations if matches_platform(c.identifier) and build_selector(c.identifier)]

# return all configurations whose arch is in our `architectures` list,
# and match the build/skip rules
return [
c for c in python_configurations
if any(c.identifier.endswith(arch) for arch in architectures)
and build_selector(c.identifier)
]


def build(options: BuildOptions) -> None:
Expand All @@ -93,7 +81,7 @@ def build(options: BuildOptions) -> None:
exit(2)

assert options.manylinux_images is not None
python_configurations = get_python_configurations(options.build_selector)
python_configurations = get_python_configurations(options.build_selector, options.architectures)
joerick marked this conversation as resolved.
Show resolved Hide resolved
platforms = [
('cp', 'manylinux_x86_64', options.manylinux_images['x86_64']),
('cp', 'manylinux_i686', options.manylinux_images['i686']),
Expand Down
9 changes: 8 additions & 1 deletion cibuildwheel/macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
import subprocess
import sys
import tempfile
import textwrap
from os import PathLike
from pathlib import Path
from typing import Dict, List, NamedTuple, Optional, Sequence, Union

from .environment import ParsedEnvironment
from .logger import log
from .util import (BuildOptions, BuildSelector, NonPlatformWheelError,
from .util import (Architecture, BuildOptions, BuildSelector, NonPlatformWheelError,
download, get_build_verbosity_extra_flags, get_pip_script,
install_certifi_script, prepare_command)

Expand Down Expand Up @@ -182,6 +183,12 @@ def setup_python(python_configuration: PythonConfiguration,


def build(options: BuildOptions) -> None:
if options.architectures != [Architecture.x86_64]:
raise ValueError(textwrap.dedent(f'''
Invalid archs option {options.architectures}. macOS only supports x86_64 for the moment.
If you want to set emulation architectures on Linux, use CIBW_ARCHS_LINUX instead.
joerick marked this conversation as resolved.
Show resolved Hide resolved
'''))

temp_dir = Path(tempfile.mkdtemp(prefix='cibuildwheel'))
built_wheel_dir = temp_dir / 'built_wheel'
repaired_wheel_dir = temp_dir / 'repaired_wheel'
Expand Down
37 changes: 37 additions & 0 deletions cibuildwheel/util.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import os
import platform as platform_module
import re
import ssl
import textwrap
import urllib.request
Expand Down Expand Up @@ -121,7 +123,42 @@ def __repr__(self):
return f'{self.__class__.__name__}{self.base_file_path!r})'


class Architecture(str, Enum):
YannickJadoul marked this conversation as resolved.
Show resolved Hide resolved
# mac/linux archs
x86_64 = 'x86_64'
i686 = 'i686'
aarch64 = 'aarch64'
ppc64le = 'ppc64le'
s390x = 's390x'

# windows archs
x86 = 'x86'
AMD64 = 'AMD64'

@staticmethod
def parse_config(config: str, platform: str) -> 'List[Architecture]':
result = []
for arch_str in re.split(r'[\s,]+', config):
if arch_str == 'auto':
result += Architecture.auto_archs(platform=platform)
else:
result.append(Architecture(arch_str))
joerick marked this conversation as resolved.
Show resolved Hide resolved
return result

@staticmethod
def auto_archs(platform: str) -> 'List[Architecture]':
joerick marked this conversation as resolved.
Show resolved Hide resolved
native_architecture = Architecture(platform_module.machine())
result = [native_architecture]
if platform == 'linux' and native_architecture == Architecture.x86_64:
# x86_64 machines can run i686 docker containers
result.append(Architecture.i686)
if platform == 'windows' and native_architecture == Architecture.AMD64:
result.append(Architecture.x86)
return result


class BuildOptions(NamedTuple):
architectures: List[Architecture]
joerick marked this conversation as resolved.
Show resolved Hide resolved
package_dir: Path
output_dir: Path
build_selector: BuildSelector
Expand Down
Loading