From 9107ae0ba6c80cadc82e9915aa7c8cac7577ebc9 Mon Sep 17 00:00:00 2001 From: Manu Garg Date: Sat, 22 Aug 2026 14:11:39 -0700 Subject: [PATCH] Add sdist metadata regression test and fix version sanitization pip re-runs setup.py inside the extracted sdist (where there is no git metadata) to prepare installation metadata. If the version cannot be recovered there, the metadata falls back to 1.0.0 and pip discards the archive with 'inconsistent version' (see #253). - Make sanitize_version() handle exact tags: v1.5.2 now sanitizes to 1.5.2 instead of keeping the leading 'v' (which was only saved by setuptools' lenient version normalization). - Add tests/test_sdist_metadata.py: builds the sdist, verifies it ships version.mk and the C sources, re-runs egg_info in a git-free copy of the archive and asserts the regenerated metadata version matches the archive name. - Run that test in the build-linux-wheels CI job. --- .github/workflows/build.yml | 3 + src/pymod/setup.py | 7 +- tests/test_sdist_metadata.py | 128 +++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 tests/test_sdist_metadata.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6d42852..63371b0 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -273,6 +273,9 @@ jobs: - name: Build sdist run: cd src/pymod && python setup.py sdist + - name: Test sdist metadata + run: python tests/test_sdist_metadata.py + - name: Install cibuildwheel and twine run: python -m pip install cibuildwheel twine diff --git a/src/pymod/setup.py b/src/pymod/setup.py index a659920..b26094c 100644 --- a/src/pymod/setup.py +++ b/src/pymod/setup.py @@ -59,8 +59,11 @@ def module_path(): def sanitize_version(ver): ver = ver.strip() # Strip first 'v' and last part from git provided versions. - # For example, v1.3.8-12-g231 becomes v1.3.8-12. - ver = re.sub(r"^v?([\d]{1,3}\.[\d]{1,3}\.[\d]{1,3}(-[\d]{1,3})).*$", "\\1", ver) + # For example, v1.3.8-12-g231 becomes 1.3.8-12, and an exact tag like + # v1.3.8 becomes 1.3.8. + ver = re.sub( + r"^v?([\d]{1,3}\.[\d]{1,3}\.[\d]{1,3}(-[\d]{1,3})?)(-.*)?$", "\\1", ver + ) # 1.3.8-12 becomes 1.3.8.dev12 return ver.replace("-", ".dev") diff --git a/tests/test_sdist_metadata.py b/tests/test_sdist_metadata.py new file mode 100644 index 0000000..688f223 --- /dev/null +++ b/tests/test_sdist_metadata.py @@ -0,0 +1,128 @@ +# Copyright (C) 2007-2026 Manu Garg. +# Author: Manu Garg +# +# pacparser is a library that provides methods to parse proxy auto-config +# (PAC) files. Please read README file included with this package for more +# information about this library. +# +# pacparser is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. + +# pacparser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. + +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +""" +Regression test for the python sdist (see issue #253). + +pip prepares an sdist's metadata by re-running setup.py inside the +extracted archive, where there is no git metadata. The sdist must +therefore ship everything setup.py needs to recover the right version +(a version.mk pinning it) and to build the extension (the C sources). + +This test builds the sdist, checks the shipped files, then re-runs +egg_info in a git-free copy of the archive and verifies that the +regenerated metadata version matches the archive name. + +Usage: python tests/test_sdist_metadata.py +""" +import glob +import os +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile + + +def run(cmd, cwd): + print("+ %s (in %s)" % (" ".join(cmd), cwd)) + subprocess.check_call(cmd, cwd=cwd) + + +def extract_sdist(sdist, dest): + with tarfile.open(sdist) as tar: + try: + tar.extractall(dest, filter="data") + except TypeError: + # extractall() has no filter argument before Python 3.12. + tar.extractall(dest) + entries = glob.glob(os.path.join(dest, "*")) + assert len(entries) == 1, "unexpected sdist layout: %r" % entries + return entries[0] + + +def main(): + tests_dir = os.path.dirname(os.path.abspath(__file__)) + pymod_dir = os.path.join(os.path.dirname(tests_dir), "src", "pymod") + py = sys.executable + + tmp_dir = tempfile.mkdtemp(prefix="pysdist-test-") + try: + dist_dir = os.path.join(tmp_dir, "dist") + run([py, "setup.py", "-q", "sdist", "--dist-dir", dist_dir], + cwd=pymod_dir) + sdists = glob.glob(os.path.join(dist_dir, "pacparser-*.tar.gz")) + assert len(sdists) == 1, "expected one sdist, found %r" % sdists + sdist = sdists[0] + + archive = os.path.basename(sdist) + m = re.match(r"pacparser-(.+)\.tar\.gz$", archive) + assert m, "unexpected sdist name %r" % archive + archive_version = m.group(1) + base = "pacparser-%s" % archive_version + + with tarfile.open(sdist) as tar: + names = set(tar.getnames()) + # The sdist must be self-contained: C sources to build the + # extension and a version.mk pinning the version. + for required in ( + "version.mk", + "pacparser.c", + "pacparser.h", + "pac_utils.h", + "quickjs/quickjs.c", + "quickjs/quickjs.h", + ): + assert "%s/%s" % (base, required) in names, \ + "missing %s in sdist" % required + + # Re-run egg_info in a git-free copy of the sdist, as pip does + # when preparing the metadata for installation. + extract_dir = os.path.join(tmp_dir, "extract") + os.mkdir(extract_dir) + sdist_dir = extract_sdist(sdist, extract_dir) + run([py, "setup.py", "-q", "egg_info"], cwd=sdist_dir) + with open(os.path.join(sdist_dir, "pacparser.egg-info", "PKG-INFO")) \ + as f: + versions = [ + line.split(":", 1)[1].strip() + for line in f + if line.startswith("Version:") + ] + assert len(versions) == 1, "no Version: line in PKG-INFO" + metadata_version = versions[0] + assert metadata_version == archive_version, ( + "sdist metadata version %r does not match archive version %r" + % (metadata_version, archive_version) + ) + print("OK: %s metadata version is %s" % (archive, metadata_version)) + finally: + # The sdist build copies C sources into src/pymod; remove them so + # the source tree is left as found. + subprocess.call([py, "setup.py", "-q", "clean", "--all"], + cwd=pymod_dir) + shutil.rmtree(tmp_dir, ignore_errors=True) + + +if __name__ == "__main__": + main()