diff --git a/docs/development/bootstrap.rst b/docs/development/bootstrap.rst new file mode 100644 index 0000000000..164c5c8881 --- /dev/null +++ b/docs/development/bootstrap.rst @@ -0,0 +1,57 @@ +---------------------------- +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`` 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 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>`. 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 diff --git a/setuptools/_bootstrap.py b/setuptools/_bootstrap.py new file mode 100644 index 0000000000..34b632d85d --- /dev/null +++ b/setuptools/_bootstrap.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import argparse +import subprocess +import sys +import tempfile +from pathlib import Path + +__all__: list[str] = [] # No public function, only CLI is provided. + + +def _build(output_dir: Path) -> None: + """Emulate as close as possible the way a build frontend would work.""" + # 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}` ****") + + # 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") + _hook("build_wheel", root, output_dir) + + wheel = _find_or_halt(output_dir, "setuptools*.whl", "Error building wheel") + print(f"**** wheel created in `{wheel}` ****") + + +def _find_or_halt(parent: Path, pattern: str, error: str) -> Path: + if file := next(parent.glob(pattern), None): + return file + 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. " + "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.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "--output-dir", + type=Path, + default="./dist", + help="Where to store the build artifacts", + ) + params = parser.parse_args() + 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) + + +if __name__ == "__main__": + _cli() diff --git a/setuptools/tests/test_bootstrap.py b/setuptools/tests/test_bootstrap.py new file mode 100644 index 0000000000..7a5ee4fd70 --- /dev/null +++ b/setuptools/tests/test_bootstrap.py @@ -0,0 +1,55 @@ +import os +import shutil + +import pytest + +from setuptools.archive_util import unpack_archive + +CMD = ["python", "-m", "setuptools._bootstrap"] + + +@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): + 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)]) + + # 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() + + # 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")