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 10 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,5 @@ include pytest.ini
include tox.ini
include setuptools/tests/config/setupcfg_examples.txt
global-exclude *.py[cod] __pycache__
prune dist
prune build
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised this behavior isn't the default. Is it the case that if a dist directory existed prior to this change, it would end up in the sdist? That seems like bad default behavior and would have come up in other projects. I'd like to avoid this concern if possible. It's another case where adopting setuptools_scm will also bypass the concern.

Copy link
Contributor Author

@abravalheri abravalheri May 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably don't need this one. The problem is likely to be with tool.setuptools.packages.find: #4394.

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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ exclude = [
"newsfragments*",
"docs",
"docs.*",
"build*",
Copy link
Contributor Author

@abravalheri abravalheri May 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When testing localy, I found pesky build/lib/**/*.py files being included in the wheel if the build directory was not empty.

This is also the reason why I changed MANIFEST.in.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh. That doesn't seem right... adding a concern that seemingly affects all builds for something that's only affected when a bootstrap was run. Perhaps instead the bootstrap routine could remove the build directory after running?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would make more sense if the routine could remove the build directory before running...

I noted that if build exists find_packages will include it. Once it is listed in packages, changing MANIFEST.in does not seem to make an effect.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make more sense if instead we do:

[tool.setuptools.packages.find]
include = [
  "setuptools*",
  "pkg_resources*",
  "_distutils_hack*",
]
exclude = [
	"*.tests",
	"*.tests.*",
]

I think this simplifies things a bit and it is optimised for the case when the probability of adding .py in places outside the intended package directories is more likely than adding new package directories.

If the probabilities are reversed, then it makes less sense.

Copy link
Contributor Author

@abravalheri abravalheri May 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ugh. That doesn't seem right... adding a concern that seemingly affects all builds for something that's only affected when a bootstrap was run. Perhaps instead the bootstrap routine could remove the build directory after running?

Probably the reason why python -m build works is because build copies the code to a temporary directory? (I guess)
But that is not a PEP 517 requirement...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I created a reproducer for the observed problem in #4394.

(other than pip install . or pip wheel ., running the test suite with tox itself also produces the build directory, so if anyone tries to run tox before pip install ., they also end-up with spurious files).

]
namespaces = true

Expand Down
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()
51 changes: 51 additions & 0 deletions setuptools/tests/test_bootstrap.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
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()
# 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)
Loading