From d6a4a9930c5733c00ba8b522924df3f47b9a4eb6 Mon Sep 17 00:00:00 2001 From: xangcastle Date: Thu, 11 Jun 2026 09:50:30 -0600 Subject: [PATCH] fix: fall back to setuptools when declared sdist build backend is unavailable --- uv/private/pep517_whl/BUILD.bazel | 13 +- uv/private/pep517_whl/build_backend.py | 113 +++++++++++++ uv/private/pep517_whl/build_backend_test.py | 176 ++++++++++++++++++++ uv/private/pep517_whl/build_helper.py | 3 + 4 files changed, 304 insertions(+), 1 deletion(-) create mode 100644 uv/private/pep517_whl/build_backend.py create mode 100644 uv/private/pep517_whl/build_backend_test.py diff --git a/uv/private/pep517_whl/BUILD.bazel b/uv/private/pep517_whl/BUILD.bazel index 685bcafcc..9a59e4ef5 100644 --- a/uv/private/pep517_whl/BUILD.bazel +++ b/uv/private/pep517_whl/BUILD.bazel @@ -1,14 +1,25 @@ load("@bazel_lib//:bzl_library.bzl", "bzl_library") +load("//py/unstable:defs.bzl", "py_venv_test") package(default_visibility = [ "//uv/private:__subpackages__", ]) exports_files( - ["build_helper.py"], + ["build_helper.py", "build_backend.py"], visibility = ["//visibility:public"], ) +py_venv_test( + name = "build_backend_test", + srcs = [ + "build_backend.py", + "build_backend_test.py", + ], + imports = ["../../.."], + main = "build_backend_test.py", +) + bzl_library( name = "rule", srcs = ["rule.bzl"], diff --git a/uv/private/pep517_whl/build_backend.py b/uv/private/pep517_whl/build_backend.py new file mode 100644 index 000000000..7c050bd2f --- /dev/null +++ b/uv/private/pep517_whl/build_backend.py @@ -0,0 +1,113 @@ +""" +Build backend compatibility helper for sdist wheel builds. + +Extracted into its own module so it can be unit-tested independently of +the build_helper.py script entry point. +""" + +import importlib.metadata +from os import path + + +def ensure_build_backend(src_dir): + """Patch pyproject.toml and generate setup.cfg if needed for buildability. + + When the declared build backend (e.g. hatchling) is not importable in the + build venv, rewrites pyproject.toml to use setuptools.build_meta. Also + generates setup.cfg for setuptools < 61.0 which does not support the + PEP 621 [project] table. + """ + pyproject_path = path.join(src_dir, "pyproject.toml") + if not path.exists(pyproject_path): + return + + with open(pyproject_path) as f: + lines = f.readlines() + + name = None + version = None + build_backend = None + in_project = False + in_build_system = False + in_project_scripts = False + has_src_layout = path.isdir(path.join(src_dir, "src")) + scripts = {} + + for line in lines: + stripped = line.strip() + if stripped == "[project]": + in_project, in_build_system, in_project_scripts = True, False, False + continue + elif stripped == "[build-system]": + in_build_system, in_project, in_project_scripts = True, False, False + continue + elif stripped == "[project.scripts]": + in_project_scripts, in_project, in_build_system = True, False, False + continue + elif stripped.startswith("["): + in_project = in_build_system = in_project_scripts = False + continue + if in_project: + if stripped.startswith("name"): + _, _, val = stripped.partition("=") + name = val.strip().strip('"').strip("'") + elif stripped.startswith("version"): + _, _, val = stripped.partition("=") + version = val.strip().strip('"').strip("'") + elif in_build_system: + if stripped.startswith("build-backend"): + _, _, val = stripped.partition("=") + build_backend = val.strip().strip('"').strip("'") + elif in_project_scripts: + if "=" in stripped: + sname, _, sval = stripped.partition("=") + scripts[sname.strip().strip('"').strip("'")] = sval.strip().strip('"').strip("'") + + backend_available = True + if build_backend and build_backend != "setuptools.build_meta": + backend_module = build_backend.split(":")[0].split(".")[0] + try: + __import__(backend_module) + except ImportError: + backend_available = False + + if not backend_available: + new_lines = [] + in_build_system_section = False + for line in lines: + stripped = line.strip() + if stripped == "[build-system]": + in_build_system_section = True + new_lines.append("[build-system]\n") + new_lines.append('requires = ["setuptools", "wheel"]\n') + new_lines.append('build-backend = "setuptools.build_meta"\n') + continue + elif stripped.startswith("[") and in_build_system_section: + in_build_system_section = False + if in_build_system_section: + continue + new_lines.append(line) + with open(pyproject_path, "w") as f: + f.writelines(new_lines) + build_backend = "setuptools.build_meta" + + if build_backend == "setuptools.build_meta" or build_backend is None: + try: + st_ver = tuple(int(x) for x in importlib.metadata.version("setuptools").split(".")[:2]) + except Exception: + st_ver = (0, 0) + + setup_cfg_path = path.join(src_dir, "setup.cfg") + if st_ver < (61, 0) and name and version and not path.exists(setup_cfg_path): + cfg = "[metadata]\nname = {}\nversion = {}\n".format(name, version) + if has_src_layout: + cfg += "\n[options]\npackage_dir =\n = src\npackages = find:\n\n[options.packages.find]\nwhere = src\n" + else: + cfg += "\n[options]\npackages = find:\n" + cfg += "\n[options.package_data]\n* = *\n" + if scripts: + cfg += "\n[options.entry_points]\nconsole_scripts =\n" + for sname, sval in scripts.items(): + cfg += " {} = {}\n".format(sname, sval) + with open(setup_cfg_path, "w") as f: + f.write(cfg) diff --git a/uv/private/pep517_whl/build_backend_test.py b/uv/private/pep517_whl/build_backend_test.py new file mode 100644 index 000000000..1174390e6 --- /dev/null +++ b/uv/private/pep517_whl/build_backend_test.py @@ -0,0 +1,176 @@ +"""Unit tests for build_backend.ensure_build_backend.""" + +import os +import sys +import tempfile + +from uv.private.pep517_whl.build_backend import ensure_build_backend + + +def _make_project(members): + """Write a dict of {relative_path: content} into a temp directory.""" + d = tempfile.mkdtemp() + for rel, content in members.items(): + full = os.path.join(d, rel) + os.makedirs(os.path.dirname(full), exist_ok=True) + with open(full, "w") as f: + f.write(content) + return d + + +def _read(d, rel): + with open(os.path.join(d, rel)) as f: + return f.read() + + +def _pyproject(backend, name="pkg", version="1.0"): + return ( + "[project]\n" + 'name = "{}"\n' + 'version = "{}"\n' + "\n" + "[build-system]\n" + 'requires = ["{}"]' + '\nbuild-backend = "{}"\n' + ).format(name, version, backend.split(":")[0].split(".")[0], backend) + + +# --------------------------------------------------------------------------- +# Available backend — no mutation +# --------------------------------------------------------------------------- + +def test_available_backend_not_mutated(): + """When the declared backend is importable, pyproject.toml is left alone.""" + original = _pyproject("setuptools.build_meta") + d = _make_project({"pyproject.toml": original}) + ensure_build_backend(d) + assert _read(d, "pyproject.toml") == original + + +# --------------------------------------------------------------------------- +# Unavailable backend — fallback to setuptools +# --------------------------------------------------------------------------- + +def test_unavailable_backend_rewritten(): + """A non-importable backend causes pyproject.toml to be rewritten.""" + d = _make_project({"pyproject.toml": _pyproject("_nonexistent_backend_xyz_")}) + ensure_build_backend(d) + content = _read(d, "pyproject.toml") + assert 'build-backend = "setuptools.build_meta"' in content + assert "_nonexistent_backend_xyz_" not in content + + +def test_unavailable_backend_preserves_project_section(): + """Rewriting build-system must not strip the [project] table.""" + d = _make_project({"pyproject.toml": _pyproject("_nonexistent_backend_xyz_", name="mypkg", version="2.3")}) + ensure_build_backend(d) + content = _read(d, "pyproject.toml") + assert "[project]" in content + assert 'name = "mypkg"' in content + assert 'version = "2.3"' in content + + +# --------------------------------------------------------------------------- +# No pyproject.toml — no-op +# --------------------------------------------------------------------------- + +def test_no_pyproject_is_noop(): + d = tempfile.mkdtemp() + ensure_build_backend(d) # must not raise + + +# --------------------------------------------------------------------------- +# setup.cfg generation for old setuptools (mocked via monkeypatching) +# --------------------------------------------------------------------------- + +def test_setup_cfg_generated_for_old_setuptools(monkeypatch=None): + """When setuptools < 61, a setup.cfg is generated from [project] metadata.""" + import importlib.metadata + import uv.private.pep517_whl.build_backend as mod + + original_version = importlib.metadata.version + + def fake_version(name): + if name == "setuptools": + return "60.0.0" + return original_version(name) + + mod_metadata = mod.importlib.metadata + orig = mod_metadata.version + mod_metadata.version = fake_version + try: + d = _make_project({"pyproject.toml": _pyproject("setuptools.build_meta", name="mypkg", version="3.1")}) + ensure_build_backend(d) + assert os.path.exists(os.path.join(d, "setup.cfg")) + cfg = _read(d, "setup.cfg") + assert "name = mypkg" in cfg + assert "version = 3.1" in cfg + finally: + mod_metadata.version = orig + + +def test_setup_cfg_not_generated_for_new_setuptools(): + """When setuptools >= 61, setup.cfg is NOT generated.""" + d = _make_project({"pyproject.toml": _pyproject("setuptools.build_meta", name="mypkg", version="1.0")}) + ensure_build_backend(d) + assert not os.path.exists(os.path.join(d, "setup.cfg")) + + +def test_existing_setup_cfg_not_overwritten(): + """An existing setup.cfg must never be overwritten.""" + import importlib.metadata + import uv.private.pep517_whl.build_backend as mod + + original_content = "[metadata]\nname = original\n" + mod_metadata = mod.importlib.metadata + orig = mod_metadata.version + mod_metadata.version = lambda n: "60.0.0" if n == "setuptools" else orig(n) + try: + d = _make_project({ + "pyproject.toml": _pyproject("setuptools.build_meta", name="mypkg", version="1.0"), + "setup.cfg": original_content, + }) + ensure_build_backend(d) + assert _read(d, "setup.cfg") == original_content + finally: + mod_metadata.version = orig + + +# --------------------------------------------------------------------------- +# [project.scripts] propagated into setup.cfg entry_points +# --------------------------------------------------------------------------- + +def test_scripts_in_setup_cfg(monkeypatch=None): + import uv.private.pep517_whl.build_backend as mod + + pyproject = ( + '[project]\nname = "cli"\nversion = "1.0"\n\n' + '[project.scripts]\ncli-tool = "cli.main:main"\n\n' + '[build-system]\nrequires = ["setuptools"]\nbuild-backend = "setuptools.build_meta"\n' + ) + mod_metadata = mod.importlib.metadata + orig = mod_metadata.version + mod_metadata.version = lambda n: "60.0.0" if n == "setuptools" else orig(n) + try: + d = _make_project({"pyproject.toml": pyproject}) + ensure_build_backend(d) + cfg = _read(d, "setup.cfg") + assert "console_scripts" in cfg + assert "cli-tool" in cfg + finally: + mod_metadata.version = orig + + +if __name__ == "__main__": + failures = [] + fns = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + for fn in fns: + try: + fn() + print(" PASS {}".format(fn.__name__)) + except Exception as e: + print(" FAIL {}: {}".format(fn.__name__, e)) + failures.append(fn.__name__) + print("\n{} passed, {} failed".format(len(fns) - len(failures), len(failures))) + if failures: + sys.exit(1) diff --git a/uv/private/pep517_whl/build_helper.py b/uv/private/pep517_whl/build_helper.py index e18b7f48c..02db42cd2 100644 --- a/uv/private/pep517_whl/build_helper.py +++ b/uv/private/pep517_whl/build_helper.py @@ -41,6 +41,9 @@ ) +from uv.private.pep517_whl.build_backend import ensure_build_backend +ensure_build_backend(t) + # Get a path to the outdir which will be valid after we cd outdir = path.abspath(opts.outdir)