Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add tool to help with "bootstraping" build #4389

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
57 changes: 57 additions & 0 deletions docs/development/bootstrap.rst
Original file line number Diff line number Diff line change
@@ -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 <PyPUG:Wheel>` 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>`.
1 change: 1 addition & 0 deletions docs/development/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ setuptools changes. You have been warned.

developer-guide
releases
bootstrap
65 changes: 65 additions & 0 deletions setuptools/_bootstrap.py
Original file line number Diff line number Diff line change
@@ -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()
55 changes: 55 additions & 0 deletions setuptools/tests/test_bootstrap.py
Original file line number Diff line number Diff line change
@@ -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")
Loading