Skip to content

Commit e868f84

Browse files
authored
Merge pull request #48 from pypa/feature/build-meta-command
New command pep517.meta
2 parents 075cd2d + 205d128 commit e868f84

10 files changed

+313
-36
lines changed

dev-requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@ mock
44
testpath
55
pytoml
66
setuptools>=30
7+
importlib_metadata
8+
zipp

pep517/build.py

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,56 @@
33
import argparse
44
import logging
55
import os
6-
import contextlib
76
import pytoml
87
import shutil
9-
import errno
10-
import tempfile
118

129
from .envbuild import BuildEnvironment
1310
from .wrappers import Pep517HookCaller
11+
from .dirtools import tempdir, mkdir_p
12+
from .compat import FileNotFoundError
1413

1514
log = logging.getLogger(__name__)
1615

1716

18-
@contextlib.contextmanager
19-
def tempdir():
20-
td = tempfile.mkdtemp()
17+
def validate_system(system):
18+
"""
19+
Ensure build system has the requisite fields.
20+
"""
21+
required = {'requires', 'build-backend'}
22+
if not (required <= set(system)):
23+
message = "Missing required fields: {missing}".format(
24+
missing=required-set(system),
25+
)
26+
raise ValueError(message)
27+
28+
29+
def load_system(source_dir):
30+
"""
31+
Load the build system from a source dir (pyproject.toml).
32+
"""
33+
pyproject = os.path.join(source_dir, 'pyproject.toml')
34+
with open(pyproject) as f:
35+
pyproject_data = pytoml.load(f)
36+
return pyproject_data['build-system']
37+
38+
39+
def compat_system(source_dir):
40+
"""
41+
Given a source dir, attempt to get a build system backend
42+
and requirements from pyproject.toml. Fallback to
43+
setuptools but only if the file was not found or a build
44+
system was not indicated.
45+
"""
2146
try:
22-
yield td
23-
finally:
24-
shutil.rmtree(td)
47+
system = load_system(source_dir)
48+
except (FileNotFoundError, KeyError):
49+
system = {}
50+
system.setdefault(
51+
'build-backend',
52+
'setuptools.build_meta:__legacy__',
53+
)
54+
system.setdefault('requires', ['setuptools', 'wheel'])
55+
return system
2556

2657

2758
def _do_build(hooks, env, dist, dest):
@@ -42,33 +73,16 @@ def _do_build(hooks, env, dist, dest):
4273
shutil.move(source, os.path.join(dest, os.path.basename(filename)))
4374

4475

45-
def mkdir_p(*args, **kwargs):
46-
"""Like `mkdir`, but does not raise an exception if the
47-
directory already exists.
48-
"""
49-
try:
50-
return os.mkdir(*args, **kwargs)
51-
except OSError as exc:
52-
if exc.errno != errno.EEXIST:
53-
raise
54-
55-
56-
def build(source_dir, dist, dest=None):
57-
pyproject = os.path.join(source_dir, 'pyproject.toml')
76+
def build(source_dir, dist, dest=None, system=None):
77+
system = system or load_system(source_dir)
5878
dest = os.path.join(source_dir, dest or 'dist')
5979
mkdir_p(dest)
6080

61-
with open(pyproject) as f:
62-
pyproject_data = pytoml.load(f)
63-
# Ensure the mandatory data can be loaded
64-
buildsys = pyproject_data['build-system']
65-
requires = buildsys['requires']
66-
backend = buildsys['build-backend']
67-
68-
hooks = Pep517HookCaller(source_dir, backend)
81+
validate_system(system)
82+
hooks = Pep517HookCaller(source_dir, system['build-backend'])
6983

7084
with BuildEnvironment() as env:
71-
env.pip_install(requires)
85+
env.pip_install(system['requires'])
7286
_do_build(hooks, env, dist, dest)
7387

7488

pep517/compat.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
"""Handle reading and writing JSON in UTF-8, on Python 3 and 2."""
1+
"""Python 2/3 compatibility"""
22
import json
33
import sys
44

5+
6+
# Handle reading and writing JSON in UTF-8, on Python 3 and 2.
7+
58
if sys.version_info[0] >= 3:
69
# Python 3
710
def write_json(obj, path, **kwargs):
@@ -21,3 +24,11 @@ def write_json(obj, path, **kwargs):
2124
def read_json(path):
2225
with open(path, 'rb') as f:
2326
return json.load(f)
27+
28+
29+
# FileNotFoundError
30+
31+
try:
32+
FileNotFoundError = FileNotFoundError
33+
except NameError:
34+
FileNotFoundError = IOError

pep517/dirtools.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import os
2+
import io
3+
import contextlib
4+
import tempfile
5+
import shutil
6+
import errno
7+
import zipfile
8+
9+
10+
@contextlib.contextmanager
11+
def tempdir():
12+
"""Create a temporary directory in a context manager."""
13+
td = tempfile.mkdtemp()
14+
try:
15+
yield td
16+
finally:
17+
shutil.rmtree(td)
18+
19+
20+
def mkdir_p(*args, **kwargs):
21+
"""Like `mkdir`, but does not raise an exception if the
22+
directory already exists.
23+
"""
24+
try:
25+
return os.mkdir(*args, **kwargs)
26+
except OSError as exc:
27+
if exc.errno != errno.EEXIST:
28+
raise
29+
30+
31+
def dir_to_zipfile(root):
32+
"""Construct an in-memory zip file for a directory."""
33+
buffer = io.BytesIO()
34+
zip_file = zipfile.ZipFile(buffer, 'w')
35+
for root, dirs, files in os.walk(root):
36+
for path in dirs:
37+
fs_path = os.path.join(root, path)
38+
rel_path = os.path.relpath(fs_path, root)
39+
zip_file.writestr(rel_path + '/', '')
40+
for path in files:
41+
fs_path = os.path.join(root, path)
42+
rel_path = os.path.relpath(fs_path, root)
43+
zip_file.write(fs_path, rel_path)
44+
return zip_file

pep517/envbuild.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from sysconfig import get_paths
1111
from tempfile import mkdtemp
1212

13-
from .wrappers import Pep517HookCaller
13+
from .wrappers import Pep517HookCaller, LoggerWrapper
1414

1515
log = logging.getLogger(__name__)
1616

@@ -90,9 +90,14 @@ def pip_install(self, reqs):
9090
if not reqs:
9191
return
9292
log.info('Calling pip to install %s', reqs)
93-
check_call([
93+
cmd = [
9494
sys.executable, '-m', 'pip', 'install', '--ignore-installed',
95-
'--prefix', self.path] + list(reqs))
95+
'--prefix', self.path] + list(reqs)
96+
check_call(
97+
cmd,
98+
stdout=LoggerWrapper(log, logging.INFO),
99+
stderr=LoggerWrapper(log, logging.ERROR),
100+
)
96101

97102
def __exit__(self, exc_type, exc_val, exc_tb):
98103
needs_cleanup = (

pep517/meta.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Build metadata for a project using PEP 517 hooks.
2+
"""
3+
import argparse
4+
import logging
5+
import os
6+
import shutil
7+
import functools
8+
9+
try:
10+
import importlib.metadata as imp_meta
11+
except ImportError:
12+
import importlib_metadata as imp_meta
13+
14+
try:
15+
from zipfile import Path
16+
except ImportError:
17+
from zipp import Path
18+
19+
from .envbuild import BuildEnvironment
20+
from .wrappers import Pep517HookCaller, quiet_subprocess_runner
21+
from .dirtools import tempdir, mkdir_p, dir_to_zipfile
22+
from .build import validate_system, load_system, compat_system
23+
24+
log = logging.getLogger(__name__)
25+
26+
27+
def _prep_meta(hooks, env, dest):
28+
reqs = hooks.get_requires_for_build_wheel({})
29+
log.info('Got build requires: %s', reqs)
30+
31+
env.pip_install(reqs)
32+
log.info('Installed dynamic build dependencies')
33+
34+
with tempdir() as td:
35+
log.info('Trying to build metadata in %s', td)
36+
filename = hooks.prepare_metadata_for_build_wheel(td, {})
37+
source = os.path.join(td, filename)
38+
shutil.move(source, os.path.join(dest, os.path.basename(filename)))
39+
40+
41+
def build(source_dir='.', dest=None, system=None):
42+
system = system or load_system(source_dir)
43+
dest = os.path.join(source_dir, dest or 'dist')
44+
mkdir_p(dest)
45+
validate_system(system)
46+
hooks = Pep517HookCaller(source_dir, system['build-backend'])
47+
48+
with hooks.subprocess_runner(quiet_subprocess_runner):
49+
with BuildEnvironment() as env:
50+
env.pip_install(system['requires'])
51+
_prep_meta(hooks, env, dest)
52+
53+
54+
def build_as_zip(builder=build):
55+
with tempdir() as out_dir:
56+
builder(dest=out_dir)
57+
return dir_to_zipfile(out_dir)
58+
59+
60+
def load(root):
61+
"""
62+
Given a source directory (root) of a package,
63+
return an importlib.metadata.Distribution object
64+
with metadata build from that package.
65+
"""
66+
root = os.path.expanduser(root)
67+
system = compat_system(root)
68+
builder = functools.partial(build, source_dir=root, system=system)
69+
path = Path(build_as_zip(builder))
70+
return imp_meta.PathDistribution(path)
71+
72+
73+
parser = argparse.ArgumentParser()
74+
parser.add_argument(
75+
'source_dir',
76+
help="A directory containing pyproject.toml",
77+
)
78+
parser.add_argument(
79+
'--out-dir', '-o',
80+
help="Destination in which to save the builds relative to source dir",
81+
)
82+
83+
84+
def main():
85+
args = parser.parse_args()
86+
build(args.source_dir, args.out_dir)
87+
88+
89+
if __name__ == '__main__':
90+
main()

pep517/wrappers.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import threading
12
from contextlib import contextmanager
23
import os
34
from os.path import dirname, abspath, join as pjoin
45
import shutil
5-
from subprocess import check_call
6+
from subprocess import check_call, check_output, STDOUT
67
import sys
78
from tempfile import mkdtemp
89

@@ -49,6 +50,15 @@ def default_subprocess_runner(cmd, cwd=None, extra_environ=None):
4950
check_call(cmd, cwd=cwd, env=env)
5051

5152

53+
def quiet_subprocess_runner(cmd, cwd=None, extra_environ=None):
54+
"""A method of calling the wrapper subprocess while suppressing output."""
55+
env = os.environ.copy()
56+
if extra_environ:
57+
env.update(extra_environ)
58+
59+
check_output(cmd, cwd=cwd, env=env, stderr=STDOUT)
60+
61+
5262
def norm_and_check(source_tree, requested):
5363
"""Normalise and check a backend path.
5464
@@ -228,3 +238,37 @@ def _call_hook(self, hook_name, kwargs):
228238
message=data.get('backend_error', '')
229239
)
230240
return data['return_val']
241+
242+
243+
class LoggerWrapper(threading.Thread):
244+
"""
245+
Read messages from a pipe and redirect them
246+
to a logger (see python's logging module).
247+
"""
248+
249+
def __init__(self, logger, level):
250+
threading.Thread.__init__(self)
251+
self.daemon = True
252+
253+
self.logger = logger
254+
self.level = level
255+
256+
# create the pipe and reader
257+
self.fd_read, self.fd_write = os.pipe()
258+
self.reader = os.fdopen(self.fd_read)
259+
260+
self.start()
261+
262+
def fileno(self):
263+
return self.fd_write
264+
265+
@staticmethod
266+
def remove_newline(msg):
267+
return msg[:-1] if msg.endswith(os.linesep) else msg
268+
269+
def run(self):
270+
for line in self.reader:
271+
self._write(self.remove_newline(line))
272+
273+
def _write(self, message):
274+
self.logger.log(self.level, message)

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ home-page = "https://github.com/takluyver/pep517"
1010
description-file = "README.rst"
1111
requires = [
1212
"pytoml",
13+
"importlib_metadata",
14+
"zipp",
1315
]
1416
classifiers = ["License :: OSI Approved :: MIT License"]
1517

tests/test_build.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import pytest
2+
3+
from pep517 import build
4+
5+
6+
def system(*args):
7+
return dict.fromkeys(args)
8+
9+
10+
class TestValidateSystem:
11+
def test_missing(self):
12+
with pytest.raises(ValueError):
13+
build.validate_system(system())
14+
with pytest.raises(ValueError):
15+
build.validate_system(system('requires'))
16+
with pytest.raises(ValueError):
17+
build.validate_system(system('build-backend'))
18+
with pytest.raises(ValueError):
19+
build.validate_system(system('other'))
20+
21+
def test_missing_and_extra(self):
22+
with pytest.raises(ValueError):
23+
build.validate_system(system('build-backend', 'other'))
24+
25+
def test_satisfied(self):
26+
build.validate_system(system('build-backend', 'requires'))
27+
build.validate_system(system('build-backend', 'requires', 'other'))

0 commit comments

Comments
 (0)