From 03738d4cbaa29510cec6605a5898883eab78b945 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Thu, 23 May 2024 16:45:20 +0100 Subject: [PATCH 01/12] Add bootstrap capabilities to setuptools using PEP 517 hooks --- MANIFEST.in | 2 + setuptools/_bootstrap.py | 145 +++++++++++++++++++++++++++++ setuptools/tests/test_bootstrap.py | 60 ++++++++++++ 3 files changed, 207 insertions(+) create mode 100644 setuptools/_bootstrap.py create mode 100644 setuptools/tests/test_bootstrap.py diff --git a/MANIFEST.in b/MANIFEST.in index 0643e7ee2d..dd8ca6256f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -19,3 +19,5 @@ include tox.ini include setuptools/tests/config/setupcfg_examples.txt include setuptools/config/*.schema.json global-exclude *.py[cod] __pycache__ +prune dist +prune build diff --git a/setuptools/_bootstrap.py b/setuptools/_bootstrap.py new file mode 100644 index 0000000000..82909ea876 --- /dev/null +++ b/setuptools/_bootstrap.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import argparse +import hashlib +import importlib +import json +import subprocess +import sys +import tarfile +import tempfile +import zipfile +from functools import partial +from pathlib import Path + +__all__: list[str] = [] # No public function, only CLI is provided. + + +def _install(wheel: Path, target_dir: Path) -> Path: + print(f"Installing {wheel} into {target_dir}") + with zipfile.ZipFile(wheel) as archive: + archive.extractall(target_dir) + + dist_info = _find_or_halt(target_dir, "setuptools*.dist-info", "Error installing") + _finalize_install(wheel, dist_info) + return dist_info + + +def _finalize_install(wheel: Path, dist_info: Path) -> None: + buffering = 4096 # 4K + sha = hashlib.sha256() + with wheel.open("rb") as f: + for block in iter(partial(f.read, buffering), b""): + sha.update(block) + + direct_url = { + "url": wheel.absolute().as_uri(), + "archive_info": {"hashes": {"sha256": sha.hexdigest()}}, + } + text = json.dumps(direct_url, indent=2) + (dist_info / "direct_url.json").write_text(text, encoding="utf-8") + + +def _build(output_dir: Path) -> Path: + """Emulate as close as possible the way a build frontend works""" + cmd = [sys.executable, "-m", "setuptools._bootstrap"] + store_dir = str(output_dir.absolute()) + + # Sanity check + assert Path("pyproject.toml").exists() + assert Path("bootstrap.egg-info/entry_points.txt").exists() + + # Call build_sdist hook (PEP 517) + subprocess.run([*cmd, "_private", "build_sdist", store_dir]) + sdist = _find_or_halt(output_dir, "setuptools*.tar.gz", "Error building sdist") + print(f"**** sdist created in `{sdist}` ****") + + # Call build_wheel hook (PEP 517) + kw1 = {"ignore_cleanup_errors": True} if sys.version_info >= (3, 10) else {} + with tempfile.TemporaryDirectory(**kw1) as tmp: # type: ignore[call-overload] + with tarfile.open(sdist) as tar: + kw2 = {"filter": "data"} if sys.version_info >= (3, 12) else {} + tar.extractall(path=tmp, **kw2) # type: ignore[arg-type] + + root = _find_or_halt(Path(tmp), "setuptools-*", "Error finding sdist root") + _find_or_halt(root, "pyproject.toml", "Error extracting sdist") + subprocess.run([*cmd, "_private", "build_wheel", store_dir], cwd=str(root)) + wheel = _find_or_halt(output_dir, "setuptools*.whl", "Error building wheel") + + print(f"**** wheel created in `{wheel}` ****") + return wheel + + +def _find_or_halt(parent: Path, pattern: str, error: str) -> Path: + file = next(parent.glob(pattern), None) + if not file: + raise SystemExit(f"{error}. Cannot find `{parent / pattern}`") + return file + + +def _cli(): + parser = argparse.ArgumentParser( + description="**EXPERIMENTAL** bootstrapping script for setuptools. " + "Note that this script will perform a **simplified** procedure and may not " + "provide all the guarantees of full-blown Python build-frontend and installer." + ) + parser.add_argument( + "--install-dir", + type=Path, + help="Where to install setuptools, e.g. `.venv/lib/python3.12/site-packages`, " + "when this option is not passed, the bootstrap script will skip installation " + "steps and stop after building a wheel.", + ) + parser.add_argument( + "--wheel-in-path", + action="store_true", + help="Skip build step. Setuptools wheel MUST BE the first entry in PYTHONPATH.", + ) + parser.add_argument( + "--build-output-dir", + type=Path, + default="./dist", + help="Where to store the build artifacts", + ) + params = parser.parse_args() + + if params.wheel_in_path: + try: + wheel = next( + path + for path in map(Path, sys.path) + if path.name.startswith("setuptools") and path.suffix == ".whl" + ) + except StopIteration: + raise SystemExit(f"Setuptools wheel is not present in {sys.path=}") + print(f"**** wheel found in `{wheel}` ****") + else: + output_dir = params.build_output_dir + if output_dir.exists() and len(list(output_dir.iterdir())) > 0: + # Let's avoid accidents by preventing multiple wheels in the directory + raise SystemExit(f'--build-output-dir="{output_dir}" must be empty.') + wheel = _build(output_dir) + + if params.install_dir is None: + print("Skipping instal, `--install-dir` option not passed") + elif not params.install_dir.is_dir(): + # Let's enforce install dir must be present to avoid accidents + raise SystemError(f'`--install-dir="{params.install_dir}"` does not exist.') + else: + dist_info = _install(wheel, params.install_dir) + print(f"Installation complete. Distribution metadata in `{dist_info}`.") + + +def _private(guard: str = "_private") -> None: + """Private CLI that only calls a build hook in the simplest way possible.""" + parser = argparse.ArgumentParser() + private = parser.add_subparsers().add_parser(guard) + private.add_argument("hook", choices=["build_sdist", "build_wheel"]) + private.add_argument("output_dir", type=Path) + params = parser.parse_args() + hook = getattr(importlib.import_module("setuptools.build_meta"), params.hook) + hook(params.output_dir) + + +if __name__ == "__main__": + _private() if "_private" in sys.argv else _cli() diff --git a/setuptools/tests/test_bootstrap.py b/setuptools/tests/test_bootstrap.py new file mode 100644 index 0000000000..3cdf5fa5f1 --- /dev/null +++ b/setuptools/tests/test_bootstrap.py @@ -0,0 +1,60 @@ +import os +import shutil + +import pytest +from setuptools.archive_util import unpack_archive + + +CMD = ["python", "-m", "setuptools._bootstrap"] + + +def bootstrap_run(venv, tmp_path, options=(), **kwargs): + target = tmp_path / "target" + target.mkdir() + venv.run([*CMD, *options, "--install-dir", str(target)], **kwargs) + return target + + +def verify_install(target): + # Included in wheel: + assert (target / "distutils-precedence.pth").is_file() + assert (target / "setuptools/__init__.py").is_file() + assert (target / "pkg_resources/__init__.py").is_file() + # Excluded from wheel: + assert not (target / "setuptools/tests").is_dir() + assert not (target / "pkg_resources/tests").is_dir() + + +@pytest.fixture +def setuptools_sourcetree(tmp_path, setuptools_sdist, request): + """ + Recreate the setuptools source tree. + We use sdist in a temporary directory to avoid race conditions with build/dist dirs. + """ + unpack_archive(setuptools_sdist, tmp_path) + root = next(tmp_path.glob("setuptools-*")) + # Remove sdist's metadata/cache/artifacts to simulate fresh repo + shutil.rmtree(root / "setuptools.egg-info", ignore_errors=True) + (root / "PKG-INFO").unlink() + # We need the bootstrap folder (not included in the sdist) + shutil.copytree( + os.path.join(request.config.rootdir, "bootstrap.egg-info"), + os.path.join(root, "bootstrap.egg-info"), + ) + return root + + +def test_bootstrap_sourcetree(tmp_path, bare_venv, setuptools_sourcetree): + target = bootstrap_run(bare_venv, tmp_path, cwd=str(setuptools_sourcetree)) + verify_install(target) + + +def test_bootstrap_pythonpath(tmp_path, setuptools_wheel, bare_venv): + env = {"PYTHONPATH": str(setuptools_wheel)} + if use_distutils := os.getenv("SETUPTOOLS_USE_DISTUTILS", ""): + env["SETUPTOOLS_USE_DISTUTILS"] = use_distutils + + target = bootstrap_run( + bare_venv, tmp_path, ["--wheel-in-path"], env=env, cwd=str(tmp_path) + ) + verify_install(target) From 01a21a4d71799d73edc59f0c13c46f31da254203 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Thu, 23 May 2024 16:54:33 +0100 Subject: [PATCH 02/12] Use subprocess to simplify tar and unzip --- setuptools/_bootstrap.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/setuptools/_bootstrap.py b/setuptools/_bootstrap.py index 82909ea876..a838ca8bbd 100644 --- a/setuptools/_bootstrap.py +++ b/setuptools/_bootstrap.py @@ -6,9 +6,7 @@ import json import subprocess import sys -import tarfile import tempfile -import zipfile from functools import partial from pathlib import Path @@ -17,9 +15,7 @@ def _install(wheel: Path, target_dir: Path) -> Path: print(f"Installing {wheel} into {target_dir}") - with zipfile.ZipFile(wheel) as archive: - archive.extractall(target_dir) - + subprocess.run([sys.executable, "-m", "zipfile", "-e", str(wheel), str(target_dir)]) dist_info = _find_or_halt(target_dir, "setuptools*.dist-info", "Error installing") _finalize_install(wheel, dist_info) return dist_info @@ -57,10 +53,7 @@ def _build(output_dir: Path) -> Path: # Call build_wheel hook (PEP 517) kw1 = {"ignore_cleanup_errors": True} if sys.version_info >= (3, 10) else {} with tempfile.TemporaryDirectory(**kw1) as tmp: # type: ignore[call-overload] - with tarfile.open(sdist) as tar: - kw2 = {"filter": "data"} if sys.version_info >= (3, 12) else {} - tar.extractall(path=tmp, **kw2) # type: ignore[arg-type] - + subprocess.run([sys.executable, "-m", "tarfile", "-e", str(sdist), tmp]) root = _find_or_halt(Path(tmp), "setuptools-*", "Error finding sdist root") _find_or_halt(root, "pyproject.toml", "Error extracting sdist") subprocess.run([*cmd, "_private", "build_wheel", store_dir], cwd=str(root)) From 877f4e6f96fef011d2668be7b1d455cc7b584108 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Thu, 23 May 2024 17:28:30 +0100 Subject: [PATCH 03/12] Further simplify bootstrap by delegating extraction to user --- setuptools/_bootstrap.py | 102 +++++------------------------ setuptools/tests/test_bootstrap.py | 42 +++++------- 2 files changed, 33 insertions(+), 111 deletions(-) diff --git a/setuptools/_bootstrap.py b/setuptools/_bootstrap.py index a838ca8bbd..f7d6dce9c9 100644 --- a/setuptools/_bootstrap.py +++ b/setuptools/_bootstrap.py @@ -1,126 +1,60 @@ from __future__ import annotations import argparse -import hashlib import importlib -import json import subprocess import sys import tempfile -from functools import partial from pathlib import Path __all__: list[str] = [] # No public function, only CLI is provided. -def _install(wheel: Path, target_dir: Path) -> Path: - print(f"Installing {wheel} into {target_dir}") - subprocess.run([sys.executable, "-m", "zipfile", "-e", str(wheel), str(target_dir)]) - dist_info = _find_or_halt(target_dir, "setuptools*.dist-info", "Error installing") - _finalize_install(wheel, dist_info) - return dist_info - - -def _finalize_install(wheel: Path, dist_info: Path) -> None: - buffering = 4096 # 4K - sha = hashlib.sha256() - with wheel.open("rb") as f: - for block in iter(partial(f.read, buffering), b""): - sha.update(block) - - direct_url = { - "url": wheel.absolute().as_uri(), - "archive_info": {"hashes": {"sha256": sha.hexdigest()}}, - } - text = json.dumps(direct_url, indent=2) - (dist_info / "direct_url.json").write_text(text, encoding="utf-8") - - -def _build(output_dir: Path) -> Path: - """Emulate as close as possible the way a build frontend works""" +def _build(output_dir: Path) -> None: + """Emulate as close as possible the way a build frontend would work.""" cmd = [sys.executable, "-m", "setuptools._bootstrap"] store_dir = str(output_dir.absolute()) - # Sanity check - assert Path("pyproject.toml").exists() - assert Path("bootstrap.egg-info/entry_points.txt").exists() - - # Call build_sdist hook (PEP 517) + # Call build_sdist hook subprocess.run([*cmd, "_private", "build_sdist", store_dir]) sdist = _find_or_halt(output_dir, "setuptools*.tar.gz", "Error building sdist") print(f"**** sdist created in `{sdist}` ****") - # Call build_wheel hook (PEP 517) - kw1 = {"ignore_cleanup_errors": True} if sys.version_info >= (3, 10) else {} - with tempfile.TemporaryDirectory(**kw1) as tmp: # type: ignore[call-overload] + # Call build_wheel hook from the sdist + with tempfile.TemporaryDirectory() as tmp: subprocess.run([sys.executable, "-m", "tarfile", "-e", str(sdist), tmp]) + root = _find_or_halt(Path(tmp), "setuptools-*", "Error finding sdist root") - _find_or_halt(root, "pyproject.toml", "Error extracting sdist") subprocess.run([*cmd, "_private", "build_wheel", store_dir], cwd=str(root)) - wheel = _find_or_halt(output_dir, "setuptools*.whl", "Error building wheel") + wheel = _find_or_halt(output_dir, "setuptools*.whl", "Error building wheel") print(f"**** wheel created in `{wheel}` ****") - return wheel def _find_or_halt(parent: Path, pattern: str, error: str) -> Path: - file = next(parent.glob(pattern), None) - if not file: - raise SystemExit(f"{error}. Cannot find `{parent / pattern}`") - return file + if file := next(parent.glob(pattern), None): + return file + raise SystemExit(f"{error}. Cannot find `{parent / pattern}`") -def _cli(): +def _cli() -> None: parser = argparse.ArgumentParser( description="**EXPERIMENTAL** bootstrapping script for setuptools. " "Note that this script will perform a **simplified** procedure and may not " - "provide all the guarantees of full-blown Python build-frontend and installer." + "provide all the guarantees of full-blown Python build-frontend.\n" + "To install the created wheel, please extract it into the relevant directory." ) parser.add_argument( - "--install-dir", - type=Path, - help="Where to install setuptools, e.g. `.venv/lib/python3.12/site-packages`, " - "when this option is not passed, the bootstrap script will skip installation " - "steps and stop after building a wheel.", - ) - parser.add_argument( - "--wheel-in-path", - action="store_true", - help="Skip build step. Setuptools wheel MUST BE the first entry in PYTHONPATH.", - ) - parser.add_argument( - "--build-output-dir", + "--output-dir", type=Path, default="./dist", help="Where to store the build artifacts", ) params = parser.parse_args() - - if params.wheel_in_path: - try: - wheel = next( - path - for path in map(Path, sys.path) - if path.name.startswith("setuptools") and path.suffix == ".whl" - ) - except StopIteration: - raise SystemExit(f"Setuptools wheel is not present in {sys.path=}") - print(f"**** wheel found in `{wheel}` ****") - else: - output_dir = params.build_output_dir - if output_dir.exists() and len(list(output_dir.iterdir())) > 0: - # Let's avoid accidents by preventing multiple wheels in the directory - raise SystemExit(f'--build-output-dir="{output_dir}" must be empty.') - wheel = _build(output_dir) - - if params.install_dir is None: - print("Skipping instal, `--install-dir` option not passed") - elif not params.install_dir.is_dir(): - # Let's enforce install dir must be present to avoid accidents - raise SystemError(f'`--install-dir="{params.install_dir}"` does not exist.') - else: - dist_info = _install(wheel, params.install_dir) - print(f"Installation complete. Distribution metadata in `{dist_info}`.") + if params.output_dir.exists() and len(list(params.output_dir.iterdir())) > 0: + # Let's avoid accidents by preventing multiple wheels in the directory + raise SystemExit(f'--output-dir="{params.output_dir}" must be empty.') + _build(params.output_dir) def _private(guard: str = "_private") -> None: diff --git a/setuptools/tests/test_bootstrap.py b/setuptools/tests/test_bootstrap.py index 3cdf5fa5f1..58c836ffe7 100644 --- a/setuptools/tests/test_bootstrap.py +++ b/setuptools/tests/test_bootstrap.py @@ -8,23 +8,6 @@ CMD = ["python", "-m", "setuptools._bootstrap"] -def bootstrap_run(venv, tmp_path, options=(), **kwargs): - target = tmp_path / "target" - target.mkdir() - venv.run([*CMD, *options, "--install-dir", str(target)], **kwargs) - return target - - -def verify_install(target): - # Included in wheel: - assert (target / "distutils-precedence.pth").is_file() - assert (target / "setuptools/__init__.py").is_file() - assert (target / "pkg_resources/__init__.py").is_file() - # Excluded from wheel: - assert not (target / "setuptools/tests").is_dir() - assert not (target / "pkg_resources/tests").is_dir() - - @pytest.fixture def setuptools_sourcetree(tmp_path, setuptools_sdist, request): """ @@ -45,16 +28,21 @@ def setuptools_sourcetree(tmp_path, setuptools_sdist, request): def test_bootstrap_sourcetree(tmp_path, bare_venv, setuptools_sourcetree): - target = bootstrap_run(bare_venv, tmp_path, cwd=str(setuptools_sourcetree)) - verify_install(target) + bare_venv.run(CMD, cwd=str(setuptools_sourcetree)) + wheel = next((setuptools_sourcetree / "dist").glob("*.whl")) + assert wheel.name.startswith("setuptools") + target = tmp_path / "target" + target.mkdir() + bare_venv.run(["python", "-m", "zipfile", "-e", str(wheel), str(target)]) -def test_bootstrap_pythonpath(tmp_path, setuptools_wheel, bare_venv): - env = {"PYTHONPATH": str(setuptools_wheel)} - if use_distutils := os.getenv("SETUPTOOLS_USE_DISTUTILS", ""): - env["SETUPTOOLS_USE_DISTUTILS"] = use_distutils + # Included in wheel: + assert (target / "distutils-precedence.pth").is_file() + assert (target / "setuptools/__init__.py").is_file() + assert (target / "pkg_resources/__init__.py").is_file() + # Excluded from wheel: + assert not (target / "setuptools/tests").is_dir() + assert not (target / "pkg_resources/tests").is_dir() - target = bootstrap_run( - bare_venv, tmp_path, ["--wheel-in-path"], env=env, cwd=str(tmp_path) - ) - verify_install(target) + test = ["python", "-c", "print(__import__('setuptools').__version__)"] + bare_venv.run(test, env={"PYTHONPATH": str(target)}) From 6be92e581a03a469eb2d5319105944aa2361c582 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Thu, 23 May 2024 18:18:42 +0100 Subject: [PATCH 04/12] Make private CLI even more obvious --- setuptools/_bootstrap.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/setuptools/_bootstrap.py b/setuptools/_bootstrap.py index f7d6dce9c9..459062f759 100644 --- a/setuptools/_bootstrap.py +++ b/setuptools/_bootstrap.py @@ -9,6 +9,8 @@ __all__: list[str] = [] # No public function, only CLI is provided. +_PRIVATE = "_private._dont_call_directly" + def _build(output_dir: Path) -> None: """Emulate as close as possible the way a build frontend would work.""" @@ -16,7 +18,7 @@ def _build(output_dir: Path) -> None: store_dir = str(output_dir.absolute()) # Call build_sdist hook - subprocess.run([*cmd, "_private", "build_sdist", store_dir]) + subprocess.run([*cmd, _PRIVATE, "build_sdist", store_dir]) sdist = _find_or_halt(output_dir, "setuptools*.tar.gz", "Error building sdist") print(f"**** sdist created in `{sdist}` ****") @@ -25,7 +27,7 @@ def _build(output_dir: Path) -> None: subprocess.run([sys.executable, "-m", "tarfile", "-e", str(sdist), tmp]) root = _find_or_halt(Path(tmp), "setuptools-*", "Error finding sdist root") - subprocess.run([*cmd, "_private", "build_wheel", store_dir], cwd=str(root)) + subprocess.run([*cmd, _PRIVATE, "build_wheel", store_dir], cwd=str(root)) wheel = _find_or_halt(output_dir, "setuptools*.whl", "Error building wheel") print(f"**** wheel created in `{wheel}` ****") @@ -57,7 +59,7 @@ def _cli() -> None: _build(params.output_dir) -def _private(guard: str = "_private") -> None: +def _private(guard: str = _PRIVATE) -> None: """Private CLI that only calls a build hook in the simplest way possible.""" parser = argparse.ArgumentParser() private = parser.add_subparsers().add_parser(guard) @@ -69,4 +71,4 @@ def _private(guard: str = "_private") -> None: if __name__ == "__main__": - _private() if "_private" in sys.argv else _cli() + _private() if _PRIVATE in sys.argv else _cli() From d0e3f512104963bbe7b3d8c746ffe3e5d8442199 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Thu, 23 May 2024 18:19:25 +0100 Subject: [PATCH 05/12] Add docs for bootstrapping --- docs/development/bootstrap.rst | 50 ++++++++++++++++++++++++++++++++++ docs/development/index.rst | 1 + 2 files changed, 51 insertions(+) create mode 100644 docs/development/bootstrap.rst diff --git a/docs/development/bootstrap.rst b/docs/development/bootstrap.rst new file mode 100644 index 0000000000..6837a251f6 --- /dev/null +++ b/docs/development/bootstrap.rst @@ -0,0 +1,50 @@ +---------------------------- +Bootstrapping ``setuptools`` +---------------------------- + +If you need to *build* ``setuptools`` without the help of any third party tool +(like :pypi:`build` or :pypi:`pip`), you can use the following procedure: + +1. Obtain ``setuptools``'s source code and change to the project root directory. + For example:: + + $ git clone https://github.com/pypa/setuptools + $ cd setuptools + +2. Run the bootstrap utility with the version of Python you intend to use + ``setuptools`` with:: + + $ python3 -m setuptools._bootstrap + + This will create a :term:`setuptools-*.whl ` file in the ``./dist`` directory. + +Furthermore, if you also need to bootstrap the *installation* of ``setuptools``, +you can follow the additional steps: + +3. Find out the directory where Python expects packages to be installed. + The following command can help with that:: + + $ python3 -m sysconfig + + Since ``setuptools`` is a pure-Python distribution, + usually you will only need the path referring to ``purelib``. + +4. Extract the created ``.whl`` file into the relevant directory. + For example:: + + $ python3 -m zipfile -e ./dist/setuptools-*.whl $TARGET_DIR + + +Notes +~~~~~ + +This procedure assumes that you have access to a fully functional Python +installation, including the standard library. + +The ``setuptools._bootstrap`` tools is still experimental +and it is named with a ``_`` character because it is not intended for general +use, only in the cases when developers (or downstream packaging consumers) +need to deploy ``setuptools`` from scratch. + +This is a CLI-only tool, with no API provided. +Users interested in API usage are invited to follow :pep:`PyPA's build-system spec <517>`. diff --git a/docs/development/index.rst b/docs/development/index.rst index 7ee52361ec..885c5e9689 100644 --- a/docs/development/index.rst +++ b/docs/development/index.rst @@ -32,3 +32,4 @@ setuptools changes. You have been warned. developer-guide releases + bootstrap From 454c48f41cfec95918948e63e561e9cc38e76dc2 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Thu, 23 May 2024 18:32:39 +0100 Subject: [PATCH 06/12] Further caveats in bootstrap documentation --- docs/development/bootstrap.rst | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/development/bootstrap.rst b/docs/development/bootstrap.rst index 6837a251f6..164c5c8881 100644 --- a/docs/development/bootstrap.rst +++ b/docs/development/bootstrap.rst @@ -41,10 +41,17 @@ Notes This procedure assumes that you have access to a fully functional Python installation, including the standard library. -The ``setuptools._bootstrap`` tools is still experimental -and it is named with a ``_`` character because it is not intended for general -use, only in the cases when developers (or downstream packaging consumers) -need to deploy ``setuptools`` from scratch. +The ``setuptools._bootstrap`` tool is a modest bare-bones implementation +that follows the :pep:`PyPA's build-system spec <517>`, +simplified and stripped down to only support the ``setuptools`` package. -This is a CLI-only tool, with no API provided. +This procedure is not intended for other packages, it will not +provide the same guarantees as a proper Python package installer +or build-frontend tool, and it is still experimental. + +The naming intentionally uses a ``_`` character to discourage +regular users, as the tool is only provided for developers (or downstream packaging +consumers) that need to deploy ``setuptools`` from scratch. + +This is a CLI-only implementation, with no API provided. Users interested in API usage are invited to follow :pep:`PyPA's build-system spec <517>`. From addc8dc2a30effa56ba91dd0d914cc7a96fbd9ff Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Thu, 23 May 2024 18:42:05 +0100 Subject: [PATCH 07/12] Show default values in argparser --- setuptools/_bootstrap.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/setuptools/_bootstrap.py b/setuptools/_bootstrap.py index 459062f759..cb07f35233 100644 --- a/setuptools/_bootstrap.py +++ b/setuptools/_bootstrap.py @@ -44,7 +44,8 @@ def _cli() -> None: description="**EXPERIMENTAL** bootstrapping script for setuptools. " "Note that this script will perform a **simplified** procedure and may not " "provide all the guarantees of full-blown Python build-frontend.\n" - "To install the created wheel, please extract it into the relevant directory." + "To install the created wheel, please extract it into the relevant directory.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( "--output-dir", From 843abb744b5ea3ec2867842542d41f0e0a7b62a9 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Thu, 23 May 2024 19:12:09 +0100 Subject: [PATCH 08/12] Avoid errors on Windows due to the lack of SYSTEMROOT env var --- setuptools/tests/test_bootstrap.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/setuptools/tests/test_bootstrap.py b/setuptools/tests/test_bootstrap.py index 58c836ffe7..f59c3fd23a 100644 --- a/setuptools/tests/test_bootstrap.py +++ b/setuptools/tests/test_bootstrap.py @@ -44,5 +44,8 @@ def test_bootstrap_sourcetree(tmp_path, bare_venv, setuptools_sourcetree): assert not (target / "setuptools/tests").is_dir() assert not (target / "pkg_resources/tests").is_dir() + # Avoid errors on Windows by copying env before modifying + # https://stackoverflow.com/questions/58997105 + env = {**os.environ, "PYTHONPATH": str(target)} test = ["python", "-c", "print(__import__('setuptools').__version__)"] - bare_venv.run(test, env={"PYTHONPATH": str(target)}) + bare_venv.run(test, env=env) From a04748a40fecd73538c180159c061ce85bb5abac Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Fri, 24 May 2024 10:16:39 +0100 Subject: [PATCH 09/12] Further simplify calling hooks --- setuptools/_bootstrap.py | 32 +++++++++++--------------------- 1 file changed, 11 insertions(+), 21 deletions(-) diff --git a/setuptools/_bootstrap.py b/setuptools/_bootstrap.py index cb07f35233..34b632d85d 100644 --- a/setuptools/_bootstrap.py +++ b/setuptools/_bootstrap.py @@ -1,7 +1,6 @@ from __future__ import annotations import argparse -import importlib import subprocess import sys import tempfile @@ -9,16 +8,11 @@ __all__: list[str] = [] # No public function, only CLI is provided. -_PRIVATE = "_private._dont_call_directly" - def _build(output_dir: Path) -> None: """Emulate as close as possible the way a build frontend would work.""" - cmd = [sys.executable, "-m", "setuptools._bootstrap"] - store_dir = str(output_dir.absolute()) - - # Call build_sdist hook - subprocess.run([*cmd, _PRIVATE, "build_sdist", store_dir]) + # Call build_wheel hook from CWD + _hook("build_sdist", Path.cwd(), output_dir) sdist = _find_or_halt(output_dir, "setuptools*.tar.gz", "Error building sdist") print(f"**** sdist created in `{sdist}` ****") @@ -27,7 +21,7 @@ def _build(output_dir: Path) -> None: subprocess.run([sys.executable, "-m", "tarfile", "-e", str(sdist), tmp]) root = _find_or_halt(Path(tmp), "setuptools-*", "Error finding sdist root") - subprocess.run([*cmd, _PRIVATE, "build_wheel", store_dir], cwd=str(root)) + _hook("build_wheel", root, output_dir) wheel = _find_or_halt(output_dir, "setuptools*.whl", "Error building wheel") print(f"**** wheel created in `{wheel}` ****") @@ -39,6 +33,13 @@ def _find_or_halt(parent: Path, pattern: str, error: str) -> Path: raise SystemExit(f"{error}. Cannot find `{parent / pattern}`") +def _hook(name: str, source_dir: Path, output_dir: Path) -> None: + # Call each hook in a fresh subprocess as required by PEP 517 + out = str(output_dir.absolute()) + script = f"from setuptools.build_meta import {name}; {name}({out!r})" + subprocess.run([sys.executable, "-c", script], cwd=source_dir) + + def _cli() -> None: parser = argparse.ArgumentParser( description="**EXPERIMENTAL** bootstrapping script for setuptools. " @@ -60,16 +61,5 @@ def _cli() -> None: _build(params.output_dir) -def _private(guard: str = _PRIVATE) -> None: - """Private CLI that only calls a build hook in the simplest way possible.""" - parser = argparse.ArgumentParser() - private = parser.add_subparsers().add_parser(guard) - private.add_argument("hook", choices=["build_sdist", "build_wheel"]) - private.add_argument("output_dir", type=Path) - params = parser.parse_args() - hook = getattr(importlib.import_module("setuptools.build_meta"), params.hook) - hook(params.output_dir) - - if __name__ == "__main__": - _private() if _PRIVATE in sys.argv else _cli() + _cli() From ca450919981bf78873d092ebf75477c5f906ac54 Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Fri, 24 May 2024 13:56:11 +0100 Subject: [PATCH 10/12] Remove unneeded entries from MANIFEST.in --- MANIFEST.in | 2 -- 1 file changed, 2 deletions(-) diff --git a/MANIFEST.in b/MANIFEST.in index dd8ca6256f..0643e7ee2d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -19,5 +19,3 @@ include tox.ini include setuptools/tests/config/setupcfg_examples.txt include setuptools/config/*.schema.json global-exclude *.py[cod] __pycache__ -prune dist -prune build From fae1c982fc01eeaca9270c23f25789c0ba2cd20c Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Mon, 11 Nov 2024 17:02:24 +0000 Subject: [PATCH 11/12] Fix linting error --- setuptools/tests/test_bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setuptools/tests/test_bootstrap.py b/setuptools/tests/test_bootstrap.py index f59c3fd23a..a405c18280 100644 --- a/setuptools/tests/test_bootstrap.py +++ b/setuptools/tests/test_bootstrap.py @@ -2,8 +2,8 @@ import shutil import pytest -from setuptools.archive_util import unpack_archive +from setuptools.archive_util import unpack_archive CMD = ["python", "-m", "setuptools._bootstrap"] From 62634d8d6cf3bd6a8f11f5d847fda76b9eb525ff Mon Sep 17 00:00:00 2001 From: Anderson Bravalheri Date: Mon, 11 Nov 2024 17:25:00 +0000 Subject: [PATCH 12/12] XFAIL on test files included in the wheel --- setuptools/tests/test_bootstrap.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/setuptools/tests/test_bootstrap.py b/setuptools/tests/test_bootstrap.py index a405c18280..7a5ee4fd70 100644 --- a/setuptools/tests/test_bootstrap.py +++ b/setuptools/tests/test_bootstrap.py @@ -40,12 +40,16 @@ def test_bootstrap_sourcetree(tmp_path, bare_venv, setuptools_sourcetree): assert (target / "distutils-precedence.pth").is_file() assert (target / "setuptools/__init__.py").is_file() assert (target / "pkg_resources/__init__.py").is_file() - # Excluded from wheel: - assert not (target / "setuptools/tests").is_dir() - assert not (target / "pkg_resources/tests").is_dir() # Avoid errors on Windows by copying env before modifying # https://stackoverflow.com/questions/58997105 env = {**os.environ, "PYTHONPATH": str(target)} test = ["python", "-c", "print(__import__('setuptools').__version__)"] bare_venv.run(test, env=env) + + try: + # Excluded from wheel: + assert not (target / "setuptools/tests").is_dir() + assert not (target / "pkg_resources/tests").is_dir() + except AssertionError: + pytest.xfail("Cannot exclude tests due to #3260. See also #4479")