Skip to content

Support in tree hooks (pyproject.toml backend-path key) #46

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

Merged
merged 5 commits into from
Apr 27, 2019
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
35 changes: 34 additions & 1 deletion pep517/_in_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

It expects:
- Command line args: hook_name, control_dir
- Environment variable: PEP517_BUILD_BACKEND=entry.point:spec
- Environment variables:
PEP517_BUILD_BACKEND=entry.point:spec
PEP517_BACKEND_PATH=paths (separated with os.pathsep)
- control_dir/input.json:
- {"kwargs": {...}}

Expand All @@ -13,6 +15,7 @@
from glob import glob
from importlib import import_module
import os
import os.path
from os.path import join as pjoin
import re
import shutil
Expand All @@ -29,14 +32,41 @@ def __init__(self, traceback):
self.traceback = traceback


class BackendInvalid(Exception):
"""Raised if the backend is invalid"""
def __init__(self, message):
self.message = message


def contained_in(filename, directory):
"""Test if a file is located within the given directory."""
filename = os.path.normcase(os.path.abspath(filename))
directory = os.path.normcase(os.path.abspath(directory))
return os.path.commonprefix([filename, directory]) == directory


def _build_backend():
"""Find and load the build backend"""
# Add in-tree backend directories to the front of sys.path.
backend_path = os.environ.get('PEP517_BACKEND_PATH')
if backend_path:
extra_pathitems = backend_path.split(os.pathsep)
sys.path[:0] = extra_pathitems

ep = os.environ['PEP517_BUILD_BACKEND']
mod_path, _, obj_path = ep.partition(':')
try:
obj = import_module(mod_path)
except ImportError:
raise BackendUnavailable(traceback.format_exc())

if backend_path:
if not any(
contained_in(obj.__file__, path)
for path in extra_pathitems
):
raise BackendInvalid("Backend was not loaded from backend-path")

if obj_path:
for path_part in obj_path.split('.'):
obj = getattr(obj, path_part)
Expand Down Expand Up @@ -203,6 +233,9 @@ def main():
except BackendUnavailable as e:
json_out['no_backend'] = True
json_out['traceback'] = e.traceback
except BackendInvalid as e:
json_out['backend_invalid'] = True
json_out['backend_error'] = e.message
except GotUnsupportedOperation as e:
json_out['unsupported'] = True
json_out['traceback'] = e.traceback
Expand Down
59 changes: 56 additions & 3 deletions pep517/wrappers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ def __init__(self, traceback):
self.traceback = traceback


class BackendInvalid(Exception):
"""Will be raised if the backend is invalid."""
def __init__(self, backend_name, backend_path, message):
self.backend_name = backend_name
self.backend_path = backend_path
self.message = message


class UnsupportedOperation(Exception):
"""May be raised by build_sdist if the backend indicates that it can't."""
def __init__(self, traceback):
Expand All @@ -41,15 +49,45 @@ def default_subprocess_runner(cmd, cwd=None, extra_environ=None):
check_call(cmd, cwd=cwd, env=env)


def norm_and_check(source_tree, requested):
"""Normalise and check a backend path.

Ensure that the requested backend path is specified as a relative path,
and resolves to a location under the given source tree.

Return an absolute version of the requested path.
"""
if os.path.isabs(requested):
raise ValueError("paths must be relative")

abs_source = os.path.abspath(source_tree)
abs_requested = os.path.normpath(os.path.join(abs_source, requested))
# We have to use commonprefix for Python 2.7 compatibility. So we
# normalise case to avoid problems because commonprefix is a character
# based comparison :-(
norm_source = os.path.normcase(abs_source)
norm_requested = os.path.normcase(abs_requested)
if os.path.commonprefix([norm_source, norm_requested]) != norm_source:
raise ValueError("paths must be inside source tree")

return abs_requested


class Pep517HookCaller(object):
"""A wrapper around a source directory to be built with a PEP 517 backend.

source_dir : The path to the source directory, containing pyproject.toml.
backend : The build backend spec, as per PEP 517, from pyproject.toml.
backend_path : The backend path, as per PEP 517, from pyproject.toml.
"""
def __init__(self, source_dir, build_backend):
def __init__(self, source_dir, build_backend, backend_path=None):
self.source_dir = abspath(source_dir)
self.build_backend = build_backend
if backend_path:
backend_path = [
norm_and_check(self.source_dir, p) for p in backend_path
]
self.backend_path = backend_path
self._subprocess_runner = default_subprocess_runner

# TODO: Is this over-engineered? Maybe frontends only need to
Expand Down Expand Up @@ -143,25 +181,40 @@ def _call_hook(self, hook_name, kwargs):
# letters, digits and _, . and : characters, and will be used as a
# Python identifier, so non-ASCII content is wrong on Python 2 in
# any case).
# For backend_path, we use sys.getfilesystemencoding.
if sys.version_info[0] == 2:
build_backend = self.build_backend.encode('ASCII')
else:
build_backend = self.build_backend
extra_environ = {'PEP517_BUILD_BACKEND': build_backend}

if self.backend_path:
backend_path = os.pathsep.join(self.backend_path)
if sys.version_info[0] == 2:
backend_path = backend_path.encode(sys.getfilesystemencoding())
Copy link
Member

Choose a reason for hiding this comment

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

This may fail if a Unix filesystem erroneously reports that it's ASCII and someone tries to use a non-ASCII path. But I don't know what the correct thing to do on Python 2 in that case is (Python 3 has os.fsencode()). So we may as well cross our fingers and hope people use either Python 3 or plain ASCII paths.

extra_environ['PEP517_BACKEND_PATH'] = backend_path

with tempdir() as td:
compat.write_json({'kwargs': kwargs}, pjoin(td, 'input.json'),
hook_input = {'kwargs': kwargs}
compat.write_json(hook_input, pjoin(td, 'input.json'),
indent=2)

# Run the hook in a subprocess
self._subprocess_runner(
[sys.executable, _in_proc_script, hook_name, td],
cwd=self.source_dir,
extra_environ={'PEP517_BUILD_BACKEND': build_backend}
extra_environ=extra_environ
)

data = compat.read_json(pjoin(td, 'output.json'))
if data.get('unsupported'):
raise UnsupportedOperation(data.get('traceback', ''))
if data.get('no_backend'):
raise BackendUnavailable(data.get('traceback', ''))
if data.get('backend_invalid'):
raise BackendInvalid(
backend_name=self.build_backend,
backend_path=self.backend_path,
message=data.get('backend_error', '')
)
return data['return_val']
2 changes: 2 additions & 0 deletions tests/samples/pkg_intree/backend/intree_backend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
def get_requires_for_build_sdist(config_settings):
return ["intree_backend_called"]
3 changes: 3 additions & 0 deletions tests/samples/pkg_intree/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[build-system]
build-backend = 'intree_backend'
backend-path = ['backend']
49 changes: 49 additions & 0 deletions tests/test_inplace_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from os.path import dirname, abspath, join as pjoin
import pytoml
from testpath import modified_env
import pytest

from pep517.wrappers import Pep517HookCaller, BackendInvalid

SAMPLES_DIR = pjoin(dirname(abspath(__file__)), 'samples')
BUILDSYS_PKGS = pjoin(SAMPLES_DIR, 'buildsys_pkgs')


def get_hooks(pkg, backend=None, path=None):
source_dir = pjoin(SAMPLES_DIR, pkg)
with open(pjoin(source_dir, 'pyproject.toml')) as f:
data = pytoml.load(f)
if backend is None:
backend = data['build-system']['build-backend']
if path is None:
path = data['build-system']['backend-path']
return Pep517HookCaller(source_dir, backend, path)


def test_backend_path_within_tree():
source_dir = pjoin(SAMPLES_DIR, 'pkg1')
assert Pep517HookCaller(source_dir, 'dummy', ['.', 'subdir'])
assert Pep517HookCaller(source_dir, 'dummy', ['../pkg1', 'subdir/..'])
# TODO: Do we want to insist on ValueError, or invent another exception?
with pytest.raises(Exception):
assert Pep517HookCaller(source_dir, 'dummy', [source_dir])
with pytest.raises(Exception):
Pep517HookCaller(source_dir, 'dummy', ['.', '..'])
with pytest.raises(Exception):
Pep517HookCaller(source_dir, 'dummy', ['subdir/../..'])
with pytest.raises(Exception):
Pep517HookCaller(source_dir, 'dummy', ['/'])


def test_intree_backend():
hooks = get_hooks('pkg_intree')
with modified_env({'PYTHONPATH': BUILDSYS_PKGS}):
res = hooks.get_requires_for_build_sdist({})
assert res == ["intree_backend_called"]


def test_intree_backend_not_in_path():
hooks = get_hooks('pkg_intree', backend='buildsys')
with modified_env({'PYTHONPATH': BUILDSYS_PKGS}):
with pytest.raises(BackendInvalid):
hooks.get_requires_for_build_sdist({})