diff --git a/.gitignore b/.gitignore
index 15c3ce3..1bfe0ca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,3 +5,5 @@ __pycache__/
.pytest_cache
doc/_build/
*.egg-info/
+.coverage
+htmlcov/
diff --git a/README.rst b/README.rst
index f0bf492..eb6c615 100644
--- a/README.rst
+++ b/README.rst
@@ -4,8 +4,11 @@ API to call PEP 517 hooks
`PEP 517 `_ specifies a standard
API for systems which build Python packages.
-This package contains wrappers around the hooks specified by PEP 517. It
-provides:
+`PEP 660 `_ extends it with a build
+mode that leads to editable installs.
+
+This package contains wrappers around the hooks specified by PEP 517 and
+PEP 660. It provides:
- A mechanism to call the hooks in a subprocess, so they are isolated from
the current process.
diff --git a/doc/callhooks.rst b/doc/callhooks.rst
index f72b914..23ee06c 100644
--- a/doc/callhooks.rst
+++ b/doc/callhooks.rst
@@ -12,12 +12,18 @@ Calling the build system
.. automethod:: get_requires_for_build_wheel
+ .. automethod:: get_requires_for_build_editable
+
.. automethod:: prepare_metadata_for_build_wheel
+ .. automethod:: prepare_metadata_for_build_editable
+
.. automethod:: build_sdist
.. automethod:: build_wheel
+ .. automethod:: build_editable
+
.. automethod:: subprocess_runner
Subprocess runners
diff --git a/doc/changelog.rst b/doc/changelog.rst
index a55eeb6..99c5a7c 100644
--- a/doc/changelog.rst
+++ b/doc/changelog.rst
@@ -4,6 +4,7 @@ Changelog
0.11
----
+- Support editable hooks (`PEP 660 `_).
- Use the TOML 1.0 compliant ``tomli`` parser module on Python 3.6 and above.
- Ensure TOML files are always read as UTF-8.
- Switch CI to Github actions.
diff --git a/pep517/in_process/_in_process.py b/pep517/in_process/_in_process.py
index a536b03..c7f5f05 100644
--- a/pep517/in_process/_in_process.py
+++ b/pep517/in_process/_in_process.py
@@ -63,6 +63,9 @@ def __init__(self, message):
class HookMissing(Exception):
"""Raised if a hook is missing and we are not executing the fallback"""
+ def __init__(self, hook_name=None):
+ super(HookMissing, self).__init__(hook_name)
+ self.hook_name = hook_name
def contained_in(filename, directory):
@@ -114,6 +117,20 @@ def get_requires_for_build_wheel(config_settings):
return hook(config_settings)
+def get_requires_for_build_editable(config_settings):
+ """Invoke the optional get_requires_for_build_editable hook
+
+ Returns [] if the hook is not defined.
+ """
+ backend = _build_backend()
+ try:
+ hook = backend.get_requires_for_build_editable
+ except AttributeError:
+ return []
+ else:
+ return hook(config_settings)
+
+
def prepare_metadata_for_build_wheel(
metadata_directory, config_settings, _allow_fallback):
"""Invoke optional prepare_metadata_for_build_wheel
@@ -127,12 +144,40 @@ def prepare_metadata_for_build_wheel(
except AttributeError:
if not _allow_fallback:
raise HookMissing()
- return _get_wheel_metadata_from_wheel(backend, metadata_directory,
+ whl_basename = backend.build_wheel(metadata_directory, config_settings)
+ return _get_wheel_metadata_from_wheel(whl_basename, metadata_directory,
config_settings)
else:
return hook(metadata_directory, config_settings)
+def prepare_metadata_for_build_editable(
+ metadata_directory, config_settings, _allow_fallback):
+ """Invoke optional prepare_metadata_for_build_editable
+
+ Implements a fallback by building an editable wheel if the hook isn't
+ defined, unless _allow_fallback is False in which case HookMissing is
+ raised.
+ """
+ backend = _build_backend()
+ try:
+ hook = backend.prepare_metadata_for_build_editable
+ except AttributeError:
+ if not _allow_fallback:
+ raise HookMissing()
+ try:
+ build_hook = backend.build_editable
+ except AttributeError:
+ raise HookMissing(hook_name='build_editable')
+ else:
+ whl_basename = build_hook(metadata_directory, config_settings)
+ return _get_wheel_metadata_from_wheel(whl_basename,
+ metadata_directory,
+ config_settings)
+ else:
+ return hook(metadata_directory, config_settings)
+
+
WHEEL_BUILT_MARKER = 'PEP517_ALREADY_BUILT_WHEEL'
@@ -149,14 +194,13 @@ def _dist_info_files(whl_zip):
def _get_wheel_metadata_from_wheel(
- backend, metadata_directory, config_settings):
- """Build a wheel and extract the metadata from it.
+ whl_basename, metadata_directory, config_settings):
+ """Extract the metadata from a wheel.
Fallback for when the build backend does not
define the 'get_wheel_metadata' hook.
"""
from zipfile import ZipFile
- whl_basename = backend.build_wheel(metadata_directory, config_settings)
with open(os.path.join(metadata_directory, WHEEL_BUILT_MARKER), 'wb'):
pass # Touch marker file
@@ -205,6 +249,27 @@ def build_wheel(wheel_directory, config_settings, metadata_directory=None):
metadata_directory)
+def build_editable(wheel_directory, config_settings, metadata_directory=None):
+ """Invoke the optional build_editable hook.
+
+ If a wheel was already built in the
+ prepare_metadata_for_build_editable fallback, this
+ will copy it rather than rebuilding the wheel.
+ """
+ backend = _build_backend()
+ try:
+ hook = backend.build_editable
+ except AttributeError:
+ raise HookMissing()
+ else:
+ prebuilt_whl = _find_already_built_wheel(metadata_directory)
+ if prebuilt_whl:
+ shutil.copy2(prebuilt_whl, wheel_directory)
+ return os.path.basename(prebuilt_whl)
+
+ return hook(wheel_directory, config_settings, metadata_directory)
+
+
def get_requires_for_build_sdist(config_settings):
"""Invoke the optional get_requires_for_build_wheel hook
@@ -242,6 +307,9 @@ def build_sdist(sdist_directory, config_settings):
'get_requires_for_build_wheel',
'prepare_metadata_for_build_wheel',
'build_wheel',
+ 'get_requires_for_build_editable',
+ 'prepare_metadata_for_build_editable',
+ 'build_editable',
'get_requires_for_build_sdist',
'build_sdist',
}
@@ -270,8 +338,9 @@ def main():
except GotUnsupportedOperation as e:
json_out['unsupported'] = True
json_out['traceback'] = e.traceback
- except HookMissing:
+ except HookMissing as e:
json_out['hook_missing'] = True
+ json_out['missing_hook_name'] = e.hook_name or hook_name
write_json(json_out, pjoin(control_dir, 'output.json'), indent=2)
diff --git a/pep517/wrappers.py b/pep517/wrappers.py
index 00974aa..52da22e 100644
--- a/pep517/wrappers.py
+++ b/pep517/wrappers.py
@@ -207,6 +207,59 @@ def build_wheel(
'metadata_directory': metadata_directory,
})
+ def get_requires_for_build_editable(self, config_settings=None):
+ """Identify packages required for building an editable wheel
+
+ Returns a list of dependency specifications, e.g.::
+
+ ["wheel >= 0.25", "setuptools"]
+
+ This does not include requirements specified in pyproject.toml.
+ It returns the result of calling the equivalently named hook in a
+ subprocess.
+ """
+ return self._call_hook('get_requires_for_build_editable', {
+ 'config_settings': config_settings
+ })
+
+ def prepare_metadata_for_build_editable(
+ self, metadata_directory, config_settings=None,
+ _allow_fallback=True):
+ """Prepare a ``*.dist-info`` folder with metadata for this project.
+
+ Returns the name of the newly created folder.
+
+ If the build backend defines a hook with this name, it will be called
+ in a subprocess. If not, the backend will be asked to build an editable
+ wheel, and the dist-info extracted from that (unless _allow_fallback is
+ False).
+ """
+ return self._call_hook('prepare_metadata_for_build_editable', {
+ 'metadata_directory': abspath(metadata_directory),
+ 'config_settings': config_settings,
+ '_allow_fallback': _allow_fallback,
+ })
+
+ def build_editable(
+ self, wheel_directory, config_settings=None,
+ metadata_directory=None):
+ """Build an editable wheel from this project.
+
+ Returns the name of the newly created file.
+
+ In general, this will call the 'build_editable' hook in the backend.
+ However, if that was previously called by
+ 'prepare_metadata_for_build_editable', and the same metadata_directory
+ is used, the previously built wheel will be copied to wheel_directory.
+ """
+ if metadata_directory is not None:
+ metadata_directory = abspath(metadata_directory)
+ return self._call_hook('build_editable', {
+ 'wheel_directory': abspath(wheel_directory),
+ 'config_settings': config_settings,
+ 'metadata_directory': metadata_directory,
+ })
+
def get_requires_for_build_sdist(self, config_settings=None):
"""Identify packages required for building a wheel
@@ -280,7 +333,7 @@ def _call_hook(self, hook_name, kwargs):
message=data.get('backend_error', '')
)
if data.get('hook_missing'):
- raise HookMissing(hook_name)
+ raise HookMissing(data.get('missing_hook_name') or hook_name)
return data['return_val']
diff --git a/tests/samples/buildsys_pkgs/buildsys.py b/tests/samples/buildsys_pkgs/buildsys.py
index e1750c1..ea9503b 100644
--- a/tests/samples/buildsys_pkgs/buildsys.py
+++ b/tests/samples/buildsys_pkgs/buildsys.py
@@ -14,11 +14,18 @@ def get_requires_for_build_wheel(config_settings):
return ['wheelwright']
+def get_requires_for_build_editable(config_settings):
+ return ['wheelwright', 'editables']
+
+
def prepare_metadata_for_build_wheel(metadata_directory, config_settings):
for distinfo in glob('*.dist-info'):
shutil.copytree(distinfo, pjoin(metadata_directory, distinfo))
+prepare_metadata_for_build_editable = prepare_metadata_for_build_wheel
+
+
def prepare_build_wheel_files(build_directory, config_settings):
shutil.copy('pyproject.toml', build_directory)
for pyfile in glob('*.py'):
@@ -37,6 +44,9 @@ def build_wheel(wheel_directory, config_settings, metadata_directory=None):
return whl_file
+build_editable = build_wheel
+
+
def get_requires_for_build_sdist(config_settings):
return ['frog']
diff --git a/tests/samples/buildsys_pkgs/buildsys_minimal_editable.py b/tests/samples/buildsys_pkgs/buildsys_minimal_editable.py
new file mode 100644
index 0000000..aabc3c7
--- /dev/null
+++ b/tests/samples/buildsys_pkgs/buildsys_minimal_editable.py
@@ -0,0 +1,4 @@
+from buildsys_minimal import build_sdist, build_wheel # noqa
+
+
+build_editable = build_wheel
diff --git a/tests/samples/pkg3/pkg3-0.5.dist-info/LICENSE b/tests/samples/pkg3/pkg3-0.5.dist-info/LICENSE
new file mode 100644
index 0000000..b0ae9db
--- /dev/null
+++ b/tests/samples/pkg3/pkg3-0.5.dist-info/LICENSE
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2017 Thomas Kluyver
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/tests/samples/pkg3/pkg3-0.5.dist-info/METADATA b/tests/samples/pkg3/pkg3-0.5.dist-info/METADATA
new file mode 100644
index 0000000..fdd9773
--- /dev/null
+++ b/tests/samples/pkg3/pkg3-0.5.dist-info/METADATA
@@ -0,0 +1,9 @@
+Metadata-Version: 1.2
+Name: pkg3
+Version: 0.5
+Summary: Sample package for tests
+Home-page: https://github.com/takluyver/pkg3
+License: UNKNOWN
+Author: Thomas Kluyver
+Author-email: thomas@kluyver.me.uk
+Classifier: License :: OSI Approved :: MIT License
diff --git a/tests/samples/pkg3/pkg3-0.5.dist-info/RECORD b/tests/samples/pkg3/pkg3-0.5.dist-info/RECORD
new file mode 100644
index 0000000..f1b3de5
--- /dev/null
+++ b/tests/samples/pkg3/pkg3-0.5.dist-info/RECORD
@@ -0,0 +1,5 @@
+pkg3.py,sha256=ZawKBtrxtdGEheOCWvwzGZsO8Q1OSzEzecGNsRz-ekc,52
+pkg3-0.5.dist-info/LICENSE,sha256=GyKwSbUmfW38I6Z79KhNjsBLn9-xpR02DkK0NCyLQVQ,1081
+pkg3-0.5.dist-info/WHEEL,sha256=jxKvNaDKHDacpaLi69-vnLKkBSynwBzmMS82pipt1T0,100
+pkg3-0.5.dist-info/METADATA,sha256=4zQxJqc4Rvnlf5Y-seXnRx8g-1FK-sjTuS0A1KP0ajk,251
+pkg3-0.5.dist-info/RECORD,,
diff --git a/tests/samples/pkg3/pkg3-0.5.dist-info/WHEEL b/tests/samples/pkg3/pkg3-0.5.dist-info/WHEEL
new file mode 100644
index 0000000..696e215
--- /dev/null
+++ b/tests/samples/pkg3/pkg3-0.5.dist-info/WHEEL
@@ -0,0 +1,5 @@
+Wheel-Version: 1.0
+Generator: flit 0.11.3
+Root-Is-Purelib: true
+Tag: py2-none-any
+Tag: py3-none-any
diff --git a/tests/samples/pkg3/pkg3.py b/tests/samples/pkg3/pkg3.py
new file mode 100644
index 0000000..b76d0a9
--- /dev/null
+++ b/tests/samples/pkg3/pkg3.py
@@ -0,0 +1,3 @@
+"""Sample package for tests"""
+
+__version__ = '0.5'
diff --git a/tests/samples/pkg3/pyproject.toml b/tests/samples/pkg3/pyproject.toml
new file mode 100644
index 0000000..966aef5
--- /dev/null
+++ b/tests/samples/pkg3/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = []
+build-backend = "buildsys_minimal_editable"
diff --git a/tests/test_call_hooks.py b/tests/test_call_hooks.py
index b145811..6161bce 100644
--- a/tests/test_call_hooks.py
+++ b/tests/test_call_hooks.py
@@ -50,6 +50,13 @@ def test_get_requires_for_build_wheel():
assert res == ['wheelwright']
+def test_get_requires_for_build_editable():
+ hooks = get_hooks('pkg1')
+ with modified_env({'PYTHONPATH': BUILDSYS_PKGS}):
+ res = hooks.get_requires_for_build_editable({})
+ assert res == ['wheelwright', 'editables']
+
+
def test_get_requires_for_build_sdist():
hooks = get_hooks('pkg1')
with modified_env({'PYTHONPATH': BUILDSYS_PKGS}):
@@ -66,6 +73,15 @@ def test_prepare_metadata_for_build_wheel():
assert_isfile(pjoin(metadatadir, 'pkg1-0.5.dist-info', 'METADATA'))
+def test_prepare_metadata_for_build_editable():
+ hooks = get_hooks('pkg1')
+ with TemporaryDirectory() as metadatadir:
+ with modified_env({'PYTHONPATH': BUILDSYS_PKGS}):
+ hooks.prepare_metadata_for_build_editable(metadatadir, {})
+
+ assert_isfile(pjoin(metadatadir, 'pkg1-0.5.dist-info', 'METADATA'))
+
+
def test_build_wheel():
hooks = get_hooks('pkg1')
with TemporaryDirectory() as builddir:
@@ -80,6 +96,20 @@ def test_build_wheel():
assert zipfile.is_zipfile(whl_file)
+def test_build_editable():
+ hooks = get_hooks('pkg1')
+ with TemporaryDirectory() as builddir:
+ with modified_env({'PYTHONPATH': BUILDSYS_PKGS}):
+ whl_file = hooks.build_editable(builddir, {})
+
+ assert whl_file.endswith('.whl')
+ assert os.sep not in whl_file
+
+ whl_file = pjoin(builddir, whl_file)
+ assert_isfile(whl_file)
+ assert zipfile.is_zipfile(whl_file)
+
+
def test_build_wheel_relpath():
hooks = get_hooks('pkg1')
with TemporaryWorkingDirectory() as builddir:
diff --git a/tests/test_hook_fallbacks.py b/tests/test_hook_fallbacks.py
index 9e61841..2a42a1f 100644
--- a/tests/test_hook_fallbacks.py
+++ b/tests/test_hook_fallbacks.py
@@ -25,6 +25,13 @@ def test_get_requires_for_build_wheel():
assert res == []
+def test_get_requires_for_build_editable():
+ hooks = get_hooks('pkg2')
+ with modified_env({'PYTHONPATH': BUILDSYS_PKGS}):
+ res = hooks.get_requires_for_build_editable({})
+ assert res == []
+
+
def test_get_requires_for_build_sdist():
hooks = get_hooks('pkg2')
with modified_env({'PYTHONPATH': BUILDSYS_PKGS}):
@@ -41,6 +48,28 @@ def test_prepare_metadata_for_build_wheel():
assert_isfile(pjoin(metadatadir, 'pkg2-0.5.dist-info', 'METADATA'))
+def test_prepare_metadata_for_build_editable():
+ hooks = get_hooks('pkg3')
+ with TemporaryDirectory() as metadatadir:
+ with modified_env({'PYTHONPATH': BUILDSYS_PKGS}):
+ hooks.prepare_metadata_for_build_editable(metadatadir, {})
+
+ assert_isfile(pjoin(metadatadir, 'pkg3-0.5.dist-info', 'METADATA'))
+
+
+def test_prepare_metadata_for_build_editable_missing_build_editable():
+ hooks = get_hooks('pkg2')
+ with TemporaryDirectory() as metadatadir:
+ with modified_env({'PYTHONPATH': BUILDSYS_PKGS}):
+ # pkg2's build system does not have build_editable
+ with pytest.raises(HookMissing) as exc_info:
+ hooks.prepare_metadata_for_build_editable(metadatadir, {})
+
+ e = exc_info.value
+ assert 'build_editable' == e.hook_name
+ assert 'build_editable' == str(e)
+
+
def test_prepare_metadata_for_build_wheel_no_fallback():
hooks = get_hooks('pkg2')
@@ -54,3 +83,18 @@ def test_prepare_metadata_for_build_wheel_no_fallback():
e = exc_info.value
assert 'prepare_metadata_for_build_wheel' == e.hook_name
assert 'prepare_metadata_for_build_wheel' in str(e)
+
+
+def test_prepare_metadata_for_build_editable_no_fallback():
+ hooks = get_hooks('pkg2')
+
+ with TemporaryDirectory() as metadatadir:
+ with modified_env({'PYTHONPATH': BUILDSYS_PKGS}):
+ with pytest.raises(HookMissing) as exc_info:
+ hooks.prepare_metadata_for_build_editable(
+ metadatadir, {}, _allow_fallback=False
+ )
+
+ e = exc_info.value
+ assert 'prepare_metadata_for_build_editable' == e.hook_name
+ assert 'prepare_metadata_for_build_editable' in str(e)