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)