Skip to content

Commit

Permalink
main: build binary distributions via sdists by default
Browse files Browse the repository at this point in the history
Fixes #257

Signed-off-by: Filipe Laíns <[email protected]>
  • Loading branch information
FFY00 committed Jun 7, 2021
1 parent 4829e00 commit fb4a2b4
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 60 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,17 @@ Unreleased
==========

- Add ``ProjectBuilder.metadata_path`` helper (`PR #303`_, Fixes `#301`_)
- Added a ``build.__main__.build_package_via_sdist`` method (`PR #304`_)

Breaking Changes
----------------

- Binary distributions are now built via the sdist by default in the CLI (`PR #304`_, Fixes `#257`_)
- ``python -m build`` will now build a sdist, extract it, and build a wheel from the source

.. _PR #303: https://github.com/pypa/build/pull/303
.. _PR #304: https://github.com/pypa/build/pull/304
.. _#257: https://github.com/pypa/build/issues/257
.. _#301: https://github.com/pypa/build/issues/301


Expand Down
140 changes: 92 additions & 48 deletions src/build/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@
from __future__ import print_function

import argparse
import contextlib
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import traceback
import warnings

from typing import Iterable, List, Optional, Sequence, TextIO, Type, Union
from typing import Iterable, Iterator, List, Optional, Sequence, TextIO, Type, Union

import build

Expand Down Expand Up @@ -57,54 +61,43 @@ def _format_dep_chain(dep_chain): # type: (Sequence[str]) -> str
return ' -> '.join(dep.partition(';')[0].strip() for dep in dep_chain)


def _build_in_isolated_env(builder, outdir, distributions, config_settings):
# type: (ProjectBuilder, str, List[str], ConfigSettingsType) -> None
for distribution in distributions:
with IsolatedEnvBuilder() as env:
builder.python_executable = env.executable
builder.scripts_dir = env.scripts_dir
# first install the build dependencies
env.install(builder.build_system_requires)
# then get the extra required dependencies from the backend (which was installed in the call above :P)
env.install(builder.get_requires_for_build(distribution))
builder.build(distribution, outdir, config_settings)


def _build_in_current_env(builder, outdir, distributions, config_settings, skip_dependency_check=False):
# type: (ProjectBuilder, str, List[str], ConfigSettingsType, bool) -> None
for dist in distributions:
if not skip_dependency_check:
missing = builder.check_dependencies(dist)
if missing:
_error(
'Missing dependencies:'
+ ''.join('\n\t' + dep for deps in missing for dep in (deps[0], _format_dep_chain(deps[1:])) if dep)
)
def _build_in_isolated_env(builder, outdir, distribution, config_settings):
# type: (ProjectBuilder, str, str, Optional[ConfigSettingsType]) -> str
with IsolatedEnvBuilder() as env:
builder.python_executable = env.executable
builder.scripts_dir = env.scripts_dir
# first install the build dependencies
env.install(builder.build_system_requires)
# then get the extra required dependencies from the backend (which was installed in the call above :P)
env.install(builder.get_requires_for_build(distribution))
return builder.build(distribution, outdir, config_settings or {})

builder.build(dist, outdir, config_settings)

def _build_in_current_env(builder, outdir, distribution, config_settings, skip_dependency_check=False):
# type: (ProjectBuilder, str, str, Optional[ConfigSettingsType], bool) -> str
if not skip_dependency_check:
missing = builder.check_dependencies(distribution)
if missing:
_error(
'Missing dependencies:'
+ ''.join('\n\t' + dep for deps in missing for dep in (deps[0], _format_dep_chain(deps[1:])) if dep)
)

def build_package(srcdir, outdir, distributions, config_settings=None, isolation=True, skip_dependency_check=False):
# type: (str, str, List[str], Optional[ConfigSettingsType], bool, bool) -> None
"""
Run the build process.
return builder.build(distribution, outdir, config_settings or {})

:param srcdir: Source directory
:param outdir: Output directory
:param distributions: Distributions to build (sdist and/or wheel)
:param config_settings: Configuration settings to be passed to the backend
:param isolation: Isolate the build in a separate environment
:param skip_dependency_check: Do not perform the dependency check
"""
if not config_settings:
config_settings = {}

def _build(isolation, builder, outdir, distribution, config_settings, skip_dependency_check):
# type: (bool, ProjectBuilder, str, str, Optional[ConfigSettingsType], bool) -> str
if isolation:
return _build_in_isolated_env(builder, outdir, distribution, config_settings)
else:
return _build_in_current_env(builder, outdir, distribution, config_settings, skip_dependency_check)


@contextlib.contextmanager
def _handle_build_error(): # type: () -> Iterator[None]
try:
builder = ProjectBuilder(srcdir)
if isolation:
_build_in_isolated_env(builder, outdir, distributions, config_settings)
else:
_build_in_current_env(builder, outdir, distributions, config_settings, skip_dependency_check)
yield
except BuildException as e:
_error(str(e))
except BuildBackendException as e:
Expand All @@ -126,6 +119,56 @@ def build_package(srcdir, outdir, distributions, config_settings=None, isolation
_error(str(e))


def build_package(srcdir, outdir, distributions, config_settings=None, isolation=True, skip_dependency_check=False):
# type: (str, str, List[str], Optional[ConfigSettingsType], bool, bool) -> None
"""
Run the build process.
:param srcdir: Source directory
:param outdir: Output directory
:param distribution: Distribution to build (sdist or wheel)
:param config_settings: Configuration settings to be passed to the backend
:param isolation: Isolate the build in a separate environment
:param skip_dependency_check: Do not perform the dependency check
"""
with _handle_build_error():
builder = ProjectBuilder(srcdir)
for distribution in distributions:
_build(isolation, builder, outdir, distribution, config_settings, skip_dependency_check)


def build_package_via_sdist(srcdir, outdir, distributions, config_settings=None, isolation=True, skip_dependency_check=False):
# type: (str, str, List[str], Optional[ConfigSettingsType], bool, bool) -> None
"""
Build a sdist and then the specified distributions from it.
:param srcdir: Source directory
:param outdir: Output directory
:param distribution: Distribution to build (only wheel)
:param config_settings: Configuration settings to be passed to the backend
:param isolation: Isolate the build in a separate environment
:param skip_dependency_check: Do not perform the dependency check
"""
if 'sdist' in distributions:
raise ValueError('Only binary distributions are allowed but sdist was specified')

with _handle_build_error():
builder = ProjectBuilder(srcdir)
sdist = _build(isolation, builder, outdir, 'sdist', config_settings, skip_dependency_check)

# extract sdist
sdist_name = os.path.basename(sdist)
sdist_out = tempfile.mkdtemp(dir=outdir, prefix='build-via-sdist-')
with tarfile.open(sdist) as t:
t.extractall(sdist_out)
builder = ProjectBuilder(os.path.join(sdist_out, sdist_name.rstrip('.tar.gz'))) # noqa: B005
for distribution in distributions:
_build(isolation, builder, outdir, distribution, config_settings, skip_dependency_check)

# remove sdist source if there was no exception
shutil.rmtree(sdist_out, ignore_errors=True)


def main_parser(): # type: () -> argparse.ArgumentParser
"""
Construct the main parser.
Expand Down Expand Up @@ -218,14 +261,15 @@ def main(cli_args, prog=None): # type: (List[str], Optional[str]) -> None
if args.wheel:
distributions.append('wheel')

# default targets
if not distributions:
distributions = ['sdist', 'wheel']

# outdir is relative to srcdir only if omitted.
outdir = os.path.join(args.srcdir, 'dist') if args.outdir is None else args.outdir

build_package(args.srcdir, outdir, distributions, config_settings, not args.no_isolation, args.skip_dependency_check)
if distributions:
build_call = build_package
else:
build_call = build_package_via_sdist
distributions = ['wheel']
build_call(args.srcdir, outdir, distributions, config_settings, not args.no_isolation, args.skip_dependency_check)


def entrypoint(): # type: () -> None
Expand Down
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ def test_bad_wheel_path(packages_path):
return os.path.join(packages_path, 'test-bad-wheel')


@pytest.fixture
def test_cant_build_via_sdist_path(packages_path):
return os.path.join(packages_path, 'test-cant-build-via-sdist')


@pytest.fixture
def test_no_permission(packages_path):
path = os.path.join(packages_path, 'test-no-permission')
Expand Down
23 changes: 23 additions & 0 deletions tests/packages/test-cant-build-via-sdist/backend_bad_sdist.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# SPDX-License-Identifier: MIT

import os.path
import tarfile
import zipfile


def build_sdist(sdist_directory, config_settings=None):
name = 'test_cant_build_via_sdist-1.0.0'
file = '{}.tar.gz'.format(name)
with tarfile.open(os.path.join(sdist_directory, file), 'w') as t:
t.add('pyproject.toml', '{}/pyproject.toml'.format(name))
t.add('backend_bad_sdist.py', '{}/backend_bad_sdist.py'.format(name))
return file


def build_wheel(wheel_directory, config_settings=None, metadata_directory=None):
if not os.path.isfile('some-file-that-is-needed-for-build.txt'):
raise FileNotFoundError('some-file-that-is-needed-for-build.txt is missing!')
# pragma: no cover
file = 'test_cant_build_via_sdist-1.0.0-py2.py3-none-any.whl'
zipfile.ZipFile(os.path.join(wheel_directory, file), 'w').close()
return file
4 changes: 4 additions & 0 deletions tests/packages/test-cant-build-via-sdist/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[build-system]
build-backend = 'backend_bad_sdist'
backend-path = ['.']
requires = []
Empty file.
3 changes: 3 additions & 0 deletions tests/packages/test-setuptools/setup.cfg
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
[metadata]
name = test_setuptools
version = 1.0.0

[bdist_wheel]
universal = 1
70 changes: 58 additions & 12 deletions tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,59 +22,82 @@


@pytest.mark.parametrize(
('cli_args', 'build_args'),
('cli_args', 'build_args', 'hook'),
[
(
[],
[cwd, out, ['sdist', 'wheel'], {}, True, False],
[cwd, out, ['wheel'], {}, True, False],
'build_package_via_sdist',
),
(
['-n'],
[cwd, out, ['sdist', 'wheel'], {}, False, False],
[cwd, out, ['wheel'], {}, False, False],
'build_package_via_sdist',
),
(
['-s'],
[cwd, out, ['sdist'], {}, True, False],
'build_package',
),
(
['-w'],
[cwd, out, ['wheel'], {}, True, False],
'build_package',
),
(
['-s', '-w'],
[cwd, out, ['sdist', 'wheel'], {}, True, False],
'build_package',
),
(
['source'],
['source', os.path.join('source', 'dist'), ['sdist', 'wheel'], {}, True, False],
['source', os.path.join('source', 'dist'), ['wheel'], {}, True, False],
'build_package_via_sdist',
),
(
['-o', 'out'],
[cwd, 'out', ['sdist', 'wheel'], {}, True, False],
[cwd, 'out', ['wheel'], {}, True, False],
'build_package_via_sdist',
),
(
['source', '-o', 'out'],
['source', 'out', ['sdist', 'wheel'], {}, True, False],
['source', 'out', ['wheel'], {}, True, False],
'build_package_via_sdist',
),
(
['-x'],
[cwd, out, ['sdist', 'wheel'], {}, True, True],
[cwd, out, ['wheel'], {}, True, True],
'build_package_via_sdist',
),
(
['-C--flag1', '-C--flag2'],
[cwd, out, ['sdist', 'wheel'], {'--flag1': '', '--flag2': ''}, True, False],
[cwd, out, ['wheel'], {'--flag1': '', '--flag2': ''}, True, False],
'build_package_via_sdist',
),
(
['-C--flag=value'],
[cwd, out, ['sdist', 'wheel'], {'--flag': 'value'}, True, False],
[cwd, out, ['wheel'], {'--flag': 'value'}, True, False],
'build_package_via_sdist',
),
(
['-C--flag1=value', '-C--flag2=other_value', '-C--flag2=extra_value'],
[cwd, out, ['sdist', 'wheel'], {'--flag1': 'value', '--flag2': ['other_value', 'extra_value']}, True, False],
[cwd, out, ['wheel'], {'--flag1': 'value', '--flag2': ['other_value', 'extra_value']}, True, False],
'build_package_via_sdist',
),
],
)
def test_parse_args(mocker, cli_args, build_args):
def test_parse_args(mocker, cli_args, build_args, hook):
mocker.patch('build.__main__.build_package')
mocker.patch('build.__main__.build_package_via_sdist')

build.__main__.main(cli_args)
build.__main__.build_package.assert_called_with(*build_args)

if hook == 'build_package':
build.__main__.build_package.assert_called_with(*build_args)
elif hook == 'build_package_via_sdist':
build.__main__.build_package_via_sdist.assert_called_with(*build_args)
else:
raise ValueError('Unknown hook {}'.format(hook)) # pragma: no cover


def test_prog():
Expand Down Expand Up @@ -169,3 +192,26 @@ def test_build_raises_build_backend_exception(mocker, test_flit_path):
build.__main__.build_package(test_flit_path, '.', ['sdist'])
msg = "Backend operation failed: Exception('a'{})".format(',' if sys.version_info < (3, 7) else '')
error.assert_called_with(msg)


def test_build_package(tmp_dir, test_setuptools_path):
build.__main__.build_package(test_setuptools_path, tmp_dir, ['sdist', 'wheel'])

assert sorted(os.listdir(tmp_dir)) == [
'test_setuptools-1.0.0-py2.py3-none-any.whl',
'test_setuptools-1.0.0.tar.gz',
]


def test_build_package_via_sdist(tmp_dir, test_setuptools_path):
build.__main__.build_package_via_sdist(test_setuptools_path, tmp_dir, ['wheel'])

assert sorted(os.listdir(tmp_dir)) == [
'test_setuptools-1.0.0-py2.py3-none-any.whl',
'test_setuptools-1.0.0.tar.gz',
]


def test_build_package_via_sdist_cant_build(tmp_dir, test_cant_build_via_sdist_path):
with pytest.raises(SystemExit):
build.__main__.build_package_via_sdist(test_cant_build_via_sdist_path, tmp_dir, ['wheel'])

0 comments on commit fb4a2b4

Please sign in to comment.