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

main: build in a virtual env by default #18

Merged
merged 5 commits into from
Jun 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 8 additions & 12 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ jobs:
- name: Install build backend
run: pip install setuptools wheel

- name: Build python-build
run: python -m build -x
- name: Build python-build (without isolation)
run: python -m build -x -n
FFY00 marked this conversation as resolved.
Show resolved Hide resolved


pip:
Expand Down Expand Up @@ -67,8 +67,8 @@ jobs:
- name: Install build backend
run: pip install setuptools wheel

- name: Build pip
run: python -m build -x -w ../pip
- name: Build pip (without isolation)
run: python -m build -x -n -w ../pip
FFY00 marked this conversation as resolved.
Show resolved Hide resolved


# python-install:
Expand Down Expand Up @@ -132,11 +132,10 @@ jobs:
if: ${{ matrix.python == 2.7 }}
run: pip install typing

- name: Install build backend
run: pip install setuptools wheel

- name: Build dateutil
- name: Build dateutil (with isolation)
run: python -m build -x -w ../dateutil
env:
SETUPTOOLS_SCM_PRETEND_VERSION: dummy


Solaar:
Expand All @@ -162,8 +161,5 @@ jobs:
- name: Install dependencies
run: pip install toml pep517 packaging importlib_metadata

- name: Install build backend
run: pip install setuptools wheel

- name: Build Solaar
- name: Build Solaar (with isolation)
run: python -m build -x -w ../solaar
31 changes: 21 additions & 10 deletions build/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@
ConfigSettings = Dict[str, Union[str, List[str]]]


_DEFAULT_BACKEND = {
'build-backend': 'setuptools.build_meta:__legacy__',
'requires': [
'setuptools >= 40.8.0',
'wheel'
]
}


class BuildException(Exception):
'''
Exception raised by ProjectBuilder
Expand Down Expand Up @@ -92,16 +101,14 @@ def __init__(self, srcdir='.', config_settings=None): # type: (str, Optional[Co
except toml.decoder.TomlDecodeError as e:
raise BuildException("Failed to parse pyproject.toml: {} ".format(e))

try:
self._build_system = self._spec['build-system']
except KeyError:
self._build_system = {
'build-backend': 'setuptools.build_meta:__legacy__',
'requires': [
'setuptools >= 40.8.0',
'wheel'
]
}
self._build_system = self._spec.get('build-system', _DEFAULT_BACKEND)
FFY00 marked this conversation as resolved.
Show resolved Hide resolved

if 'build-backend' not in self._build_system:
self._build_system['build-backend'] = _DEFAULT_BACKEND['build-backend']
self._build_system['requires'] = self._build_system.get('requires', []) + _DEFAULT_BACKEND['requires']

if 'requires' not in self._build_system:
raise BuildException("Missing 'build-system.requires' in pyproject.yml")

self._backend = self._build_system['build-backend']

Expand All @@ -113,6 +120,10 @@ def __init__(self, srcdir='.', config_settings=None): # type: (str, Optional[Co
self.hook = pep517.wrappers.Pep517HookCaller(self.srcdir, self._backend,
backend_path=self._build_system.get('backend-path'))

@property
def build_dependencies(self): # type: () -> Set[str]
return set(self._build_system['requires'])

def get_dependencies(self, distribution): # type: (str) -> Set[str]
'''
Returns a set of dependencies
Expand Down
37 changes: 27 additions & 10 deletions build/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from typing import List, Optional

from . import BuildBackendException, BuildException, ConfigSettings, ProjectBuilder
from .env import IsolatedEnvironment


__all__ = ['build', 'main', 'main_parser']
Expand All @@ -27,15 +28,34 @@ def _error(msg, code=1): # type: (str, int) -> None # pragma: no cover
exit(code)


def build(srcdir, outdir, distributions, config_settings=None, skip_dependencies=False):
# type: (str, str, List[str], Optional[ConfigSettings], bool) -> None
def _build_in_isolated_env(builder, outdir, distributions): # type: (ProjectBuilder, str, List[str]) -> None
with IsolatedEnvironment() as env:
env.install(builder.build_dependencies)
for distribution in distributions:
builder.build(distribution, outdir)


def _build_in_current_env(builder, outdir, distributions, skip_dependencies=False):
# type: (ProjectBuilder, str, List[str], bool) -> None
for dist in distributions:
if not skip_dependencies:
missing = builder.check_dependencies(dist)
if missing:
_error('Missing dependencies:' + ''.join(['\n\t' + dep for dep in missing]))
FFY00 marked this conversation as resolved.
Show resolved Hide resolved

builder.build(dist, outdir)


def build(srcdir, outdir, distributions, config_settings=None, isolation=True, skip_dependencies=False):
# type: (str, str, List[str], Optional[ConfigSettings], bool, bool) -> None
'''
Runs the build process

: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_dependencies: Do not perform the dependency check
'''
if not config_settings:
Expand All @@ -44,13 +64,10 @@ def build(srcdir, outdir, distributions, config_settings=None, skip_dependencies
try:
builder = ProjectBuilder(srcdir, config_settings)

for dist in distributions:
if not skip_dependencies:
missing = builder.check_dependencies(dist)
if missing:
_error('Missing dependencies:' + ''.join(['\n\t' + dep for dep in missing]))

builder.build(dist, outdir)
if isolation:
_build_in_isolated_env(builder, outdir, distributions)
else:
_build_in_current_env(builder, outdir, distributions, skip_dependencies)
except BuildException as e:
_error(str(e))
except BuildBackendException as e:
Expand Down Expand Up @@ -136,7 +153,7 @@ def main(cli_args, prog=None): # type: (List[str], Optional[str]) -> None
if not distributions:
distributions = ['sdist', 'wheel']

build(args.srcdir, args.outdir, distributions, config_settings, args.skip_dependencies)
build(args.srcdir, args.outdir, distributions, config_settings, not args.no_isolation, args.skip_dependencies)
gaborbernat marked this conversation as resolved.
Show resolved Hide resolved


if __name__ == '__main__': # pragma: no cover
Expand Down
93 changes: 93 additions & 0 deletions build/env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import os
import shutil
import subprocess
import sys
import sysconfig
import tempfile
import types


if False: # TYPE_CHECKING # pragma: no cover
from typing import Dict, Optional, Iterable, Type
FFY00 marked this conversation as resolved.
Show resolved Hide resolved


class IsolatedEnvironment(object):
'''
Copy link
Member

Choose a reason for hiding this comment

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

Is this ''' style already consistent across the whole package? It's very unusual to use single-quotes for docstrings, even among code-bases that exclusively use single-quotes for strings (I even have them highlighted differently from ''' strings in my syntax highlighter).

Copy link
Contributor

Choose a reason for hiding this comment

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

+1 on this

Copy link
Member Author

Choose a reason for hiding this comment

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

It is. I am open to change it, but it should come in a separate PR.

Isolated build environment context manager

Non-standard paths injected directly to sys.path still be passed to the environment.
'''

def __init__(self): # type: () -> None
self._env = {} # type: Dict[str, Optional[str]]

def _replace_env(self, key, new): # type: (str, Optional[str]) -> None
if not new: # pragma: no cover
return

self._env[key] = os.environ.get(key, None)
os.environ[key] = new

def _restore_env(self): # type: () -> None
for key, val in self._env.items():
if val is None:
os.environ.pop(key, None)
else:
os.environ[key] = val

def _get_env_path(self, path): # type: (str) -> Optional[str]
return sysconfig.get_path(path, vars=self._env_vars)

def __enter__(self): # type: () -> IsolatedEnvironment
self._path = tempfile.mkdtemp(prefix='build-env-')
self._env_vars = {
'base': self._path,
'platbase': self._path,
}

sys_path = sys.path[1:]

remove_paths = os.environ.get('PYTHONPATH', '').split(os.pathsep)

for path in ('purelib', 'platlib'):
our_path = sysconfig.get_path(path)
if our_path:
remove_paths.append(our_path)

for scheme in sysconfig.get_scheme_names():
our_path = sysconfig.get_path(path, scheme)
if our_path:
remove_paths.append(our_path)

env_path = self._get_env_path(path)
if env_path:
sys_path.append(env_path)

for path in remove_paths:
if path in sys_path:
sys_path.remove(path)

self._replace_env('PATH', self._get_env_path('scripts'))
FFY00 marked this conversation as resolved.
Show resolved Hide resolved
self._replace_env('PYTHONPATH', os.pathsep.join(sys_path))
self._replace_env('PYTHONHOME', self._path)

return self

def __exit__(self, typ, value, traceback):
# type: (Optional[Type[BaseException]], Optional[BaseException], Optional[types.TracebackType]) -> None
if self._path and os.path.isdir(self._path):
shutil.rmtree(self._path)

self._restore_env()

def install(self, requirements): # type: (Iterable[str]) -> None
'''
Installs the specified requirements on the environment
'''
if not requirements:
return

subprocess.check_call([sys.executable, '-m', 'ensurepip'], cwd=self._path)

cmd = [sys.executable, '-m', 'pip', 'install', '--ignore-installed', '--prefix', self._path] + list(requirements)
subprocess.check_call(cmd)
10 changes: 7 additions & 3 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ can use it:
usage: python-build [-h] ...


By default python-build will build the package in a isolated environment, but
this behavior can be disabled with ``--no-isolation``.


Mission Statement
=================

Expand Down Expand Up @@ -67,9 +71,9 @@ with :pep:`517` support.
``python -m pep517.build``
--------------------------

``python -m pep517.build`` will invoke pip_ to install missing dependencies,
``python-build`` is *stricly* a build tool, it does not do any sort of
dependency management. If the dependencies are not met, the build will fail.
``python-build`` implements a CLI tailored to end users. ``python -m
pep517.build`` *"implements essentially the simplest possible frontend tool,
to exercise and illustrate how the core functionality can be used"*.


Custom Behaviors
Expand Down
42 changes: 42 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# SPDX-License-Identifier: MIT

import pytest


DUMMY_PYPROJECT = '''
[build-system]
requires = ['flit_core']
build-backend = 'flit_core.buildapi'
'''.strip()


DUMMY_PYPROJECT_NO_BACKEND = '''
[build-system]
requires = []
'''.strip()


DUMMY_PYPROJECT_NO_REQUIRES = '''
[build-system]
build-backend = 'something'
'''.strip()


@pytest.fixture
def empty_file_mock(mocker):
return mocker.mock_open(read_data='')


@pytest.fixture
def pyproject_mock(mocker):
return mocker.mock_open(read_data=DUMMY_PYPROJECT)


@pytest.fixture
def pyproject_no_backend_mock(mocker):
return mocker.mock_open(read_data=DUMMY_PYPROJECT_NO_BACKEND)


@pytest.fixture
def pyproject_no_requires_mock(mocker):
return mocker.mock_open(read_data=DUMMY_PYPROJECT_NO_REQUIRES)
30 changes: 30 additions & 0 deletions tests/test_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# SPDX-License-Identifier: MIT

import os
import subprocess
import sys
import sysconfig

import build.env


def test_isolated_environment(mocker):
with build.env.IsolatedEnvironment() as env:
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this test should run in an isolated python environment (one that has no pip/setuptools/etc installed) so there's really no chance things are being pulled from some other path on the python path. E.g. is this past failing if you change from sys. get_scheme_names to that manual thing you defined before? It should if it's an accurate test.

Copy link
Member Author

Choose a reason for hiding this comment

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

That "manual thing" I had before was hardcoding the scheme names, it should work the same. Why would this be failing?

really no chance things are being pulled from some other path on the python path

What things being pulled? There is nothing being pulled. In this test I check if the environment is correct, after that I check if pip is called correctly, but we never actually call pip. I look at the path and make sure it is correct.

For eg. I import sysconfig before entering the isolated environment and make sure purelib or platlib are not there.

for path in ('purelib', 'platlib'):
    assert sysconfig.get_path(path) not in os.environ['PYTHONPATH'].split(os.pathsep)

Copy link
Contributor

Choose a reason for hiding this comment

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

I had before was hardcoding the scheme names, it should work the same.

It should have been failing on pypy, as pypy uses different scheme names then what you hardcoded. The fact that it did not, shows something is not totally correct in this sense.

For eg. I import sysconfig before entering the isolated environment and make sure purelib or platlib are not there.

To be fair I'm not sure this is enough. I'd expect we'd isolate from any potential PYTHONPATH the user might have set, or additional paths the user injects via user/sitecustomize.

Copy link
Member Author

@FFY00 FFY00 Jun 26, 2020

Choose a reason for hiding this comment

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

It shouldn't.

>>>> import sysconfig
>>>> sysconfig.get_path('purelib')
'/opt/pypy/site-packages'
>>>> sysconfig.get_path('platlib')
'/opt/pypy/site-packages'

If you read the code you'll notice I remove both the default paths and the different schemes.

https://github.com/FFY00/python-build/blob/e5efbcca2df30b7de5e14477cdbd4fb83f1d932c/build/env.py#L55

The test is correct and working as expected.

if os.name != 'nt':
assert os.environ['PATH'] == os.path.join(env._path, 'bin')
assert os.environ['PYTHONHOME'] == env._path

for path in ('purelib', 'platlib'):
assert sysconfig.get_path(path) not in os.environ['PYTHONPATH'].split(os.pathsep)

mocker.patch('subprocess.check_call')

env.install([])
subprocess.check_call.assert_not_called()

env.install(['some', 'requirements'])
if sys.version_info[:2] != (3, 5):
subprocess.check_call.assert_called()
assert subprocess.check_call.call_args[0][0] == [
sys.executable, '-m', 'pip', 'install', '--ignore-installed', '--prefix', env._path, 'some', 'requirements'
]
Loading