diff --git a/.github/build-constraints.txt b/.github/build-constraints.txt new file mode 100644 index 0000000..61eb44c --- /dev/null +++ b/.github/build-constraints.txt @@ -0,0 +1,8 @@ +# The build tools of a release: the build backend that pyproject.toml names and every +# package it needs, pinned so that .github/workflows/publish.yml builds a release as +# tests/test_release_artifacts.py builds and checks it. +packaging==26.3 +setuptools==84.2.1 +setuptools-scm==10.3.4 +vcs-versioning==2.6.0 +wheel==0.48.0 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a456ce5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,163 @@ +name: Publish to PyPI + +# Publishing a release on GitHub uploads CytePool to PyPI, built from the commit that +# the release's tag names, once that commit has passed every check below. The +# installation page of the documentation ("Make a release") describes the steps and +# the setup they need on PyPI and GitHub. +on: + release: + types: [published] + +permissions: + contents: read + +# One publication per tag at a time; a second run waits instead of racing. +concurrency: + group: publish-${{ github.event.release.tag_name }} + cancel-in-progress: false + +# Every job works on github.sha, the commit that the tag named when the release was +# published. +jobs: + tests: + name: Require a passed Tests run of the release commit + runs-on: ubuntu-26.04 + permissions: + actions: read + steps: + - name: Find the latest Tests run of the commit + env: + GH_TOKEN: ${{ github.token }} + run: | + # A pull request's run tests the merge of its branch into master, not this + # commit, so only runs started by a push or by hand count. + run="$(gh api --method GET \ + "repos/${GITHUB_REPOSITORY}/actions/workflows/tests.yml/runs" \ + -f head_sha="$GITHUB_SHA" -f per_page=100 --paginate \ + --jq '.workflow_runs[] | select(.event == "push" or .event == "workflow_dispatch") + | [.run_number, .status, (.conclusion | tostring), .html_url] | @tsv' \ + | sort -nr | sed -n '1p')" + if [[ -z "$run" ]]; then + echo "::error::No Tests run of ${GITHUB_SHA} was started by a push or by hand. Run Tests on the release tag, then re-run this workflow." + exit 1 + fi + IFS=$'\t' read -r number status conclusion url <<< "$run" + echo "Tests run ${number} of ${GITHUB_SHA}: ${status}, ${conclusion} (${url})" + if [[ "$status" != "completed" || "$conclusion" != "success" ]]; then + echo "::error::The latest Tests run of ${GITHUB_SHA} has not passed: ${url}" + exit 1 + fi + + docs: + name: Build the documentation + uses: ./.github/workflows/docs.yml + with: + ref: ${{ github.sha }} + + build: + name: Build and check the release files + runs-on: ubuntu-26.04 + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + fetch-depth: 0 + - name: Require the release tag to name the commit + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + tagged="$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}")" + if [[ "$tagged" != "$GITHUB_SHA" ]]; then + echo "::error::The tag ${RELEASE_TAG} names ${tagged}, not ${GITHUB_SHA}, the commit it named when the release was published" + exit 1 + fi + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: "3.13" + version: "0.13.5" + - name: Build the wheel and the source distribution + env: + # setuptools-scm takes the version from the tag, without a local part, and + # stops the build if the checkout has changes. + SETUPTOOLS_SCM_OVERRIDES_FOR_CYTEPOOL: '{local_scheme = "no-local-version-strict"}' + run: | + require_clean() { + status="$(git status --porcelain=v1)" + if [[ -n "$status" ]]; then + printf '%s\n' "$status" + echo "::error::The checkout has changes $1" + exit 1 + fi + } + git describe --tags --long --dirty + require_clean "before the build" + uv build --clear --build-constraints .github/build-constraints.txt + require_clean "after the build" + - name: Check the release files against the tag and the source tree + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: >- + uv run --no-project --with packaging==26.3 + python tests/validate_release_artifacts.py --release-tag "$RELEASE_TAG" dist/* + - name: Store the release files + uses: actions/upload-artifact@v7 + with: + name: python-package-distributions + path: dist/ + if-no-files-found: error + + smoke: + name: Smoke-test the wheel (${{ matrix.os }}, Python ${{ matrix.python-version }}) + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-26.04] + python-version: ["3.13", "3.14", "3.15"] + include: + - os: windows-2025 + python-version: "3.13" + - os: macos-26 + python-version: "3.13" + defaults: + run: + # Bash expands the wheel's name on every runner, Windows included. + shell: bash + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + python-version: ${{ matrix.python-version }} + version: "0.13.5" + - name: Download the release files + uses: actions/download-artifact@v8 + with: + name: python-package-distributions + path: dist/ + - name: Install the wheel in a clean environment and run a small analysis + run: uv run --no-project python tests/smoke_test_wheel.py dist/*.whl + + publish: + name: Upload to PyPI + needs: [tests, docs, build, smoke] + runs-on: ubuntu-26.04 + environment: + name: pypi + url: https://pypi.org/p/cytepool + permissions: + # Trusted publishing: PyPI accepts this workflow's OpenID Connect token for the + # project, so no API token is stored. + id-token: write + steps: + - name: Download the release files + uses: actions/download-artifact@v8 + with: + name: python-package-distributions + path: dist/ + - name: Upload to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..7c9486e --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,10 @@ +# The source distribution holds the package, the files that build it, the README and +# the licence files. setuptools-scm adds every file that git tracks; the tests, the +# documentation, the validation scripts and the repository's tooling are removed here. +# tests/validate_release_artifacts.py refuses any other file. +prune .github +prune docs +prune tests +prune validation +exclude .gitignore .python-version uv.lock *.md +include README.md THIRD_PARTY_NOTICES.md diff --git a/docs/source/installation.md b/docs/source/installation.md index fa92dc8..7fc1108 100644 --- a/docs/source/installation.md +++ b/docs/source/installation.md @@ -1,5 +1,5 @@ --- -description: Install CytePool from its repository with Python 3.13 or newer, set up a development environment, and build this documentation. +description: Install CytePool from its repository with Python 3.13 or newer, set up a development environment, build this documentation, and make a release. --- (installation)= @@ -139,3 +139,55 @@ data names its datasets in its metadata `datasets` ([Datasets](reference/datasets.md)); `make -C docs execute` prepares them. `uv run --group docs python docs/coverage_report.py` lists the public names that no page uses or explains yet. + +(make-a-release)= +## Make a release + +To release a version, check that the Tests run of the commit on master passed, then +draft a new release on GitHub, create its tag with that commit as the target, and +publish the release. The tag is the version, such as `0.1.0`, or `0.2.0rc1` for a +release candidate, which PyPI lists as a pre-release. Publishing the release starts +the [release workflow](https://github.com/NygenAnalytics/CytePool/blob/master/.github/workflows/publish.yml), +which builds CytePool from the commit that the tag names and uploads it to PyPI once +all of these pass: + +- the latest run of the Tests workflow for that commit, started by a push or by hand + (a pull request's run tests a merge commit instead), passed; +- the documentation builds, as the Docs workflow builds it; +- the wheel and the source distribution, built from a clean checkout of the commit with + the build tools pinned in `.github/build-constraints.txt`, name the version of the + tag, declare what `pyproject.toml` declares, and hold the package and no other file + of the repository (`tests/validate_release_artifacts.py`); +- the wheel, installed in a clean environment on Linux with Python 3.13, 3.14 and 3.15 + and on Windows and macOS with Python 3.13, pools a small dataset into a cube, gives + limma's tables from `limma_voom`, and loads its compiled routines from the cache in a + second run (`tests/smoke_test_wheel.py`). + +When the `pypi` environment has required reviewers, one of them approves the upload +after the checks. When a check fails, nothing is uploaded: fix the cause on master, +delete the release and its tag, and publish the release again with the fixed commit as +the target. Running the failed workflow again checks the same commit, which helps only +when the failure came from outside it, such as a network error. + +`uv run pytest` builds the two files and checks them as the workflow does. To run the +smoke test on a wheel you build: + +```bash +uv build --wheel --clear --out-dir dist/ +uv run --no-project python tests/smoke_test_wheel.py dist/cytepool-*.whl +``` + +### Before the first release + +The owner of the repository sets up two things once: + +1. On PyPI, a trusted publisher, so that the workflow uploads without a stored token: + at , add a pending publisher for + GitHub with the project name `cytepool`, the owner `NygenAnalytics`, the repository + `CytePool`, the workflow `publish.yml` and the environment `pypi`. The first upload + creates the project, owned by that account. +2. On GitHub, in the repository's settings, an environment named `pypi`: add the + people who approve uploads as required reviewers, and under *Deployment branches + and tags* choose *Selected branches and tags* and add a tag rule `*`, so that only + a run for a tag can upload. Without it, the first run creates an environment that + protects nothing. diff --git a/pyproject.toml b/pyproject.toml index d1b337d..7499a80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ test = [ [project.urls] Homepage = "https://github.com/NygenAnalytics/CytePool" +Documentation = "https://docs.nygen.io/CytePool/" Issues = "https://github.com/NygenAnalytics/CytePool/issues" [dependency-groups] diff --git a/tests/smoke_test_wheel.py b/tests/smoke_test_wheel.py new file mode 100644 index 0000000..298f0fa --- /dev/null +++ b/tests/smoke_test_wheel.py @@ -0,0 +1,192 @@ +"""Install a built wheel in a clean environment and run a small analysis with it. + +The release workflow, ``.github/workflows/publish.yml``, runs this with every Python +and on every platform it tests a release on:: + + uv run --no-project python tests/smoke_test_wheel.py dist/cytepool-*.whl + +It creates an environment with ``uv venv`` that holds only the wheel and the +dependencies it declares, resolved as a user's installation resolves them, and runs +the analysis twice with that environment's interpreter, in isolated mode from a +scratch folder, so that the package is imported from the environment, never from this +checkout or ``PYTHONPATH``. The analysis pools the cells of +``tests/data/differential_expression`` into a cube, tests the contrasts of a paired +design by ``limma_voom`` and compares the tables of the T cells with limma's. The +first run must compile the package's routines and cache them; the second must load +them all from the cache and compile none. Installing needs network access, or a uv +cache that holds the dependencies (with ``UV_OFFLINE=1``). +""" + +import argparse +import os +import subprocess +import sys +import tempfile +from pathlib import Path + +DATA = Path(__file__).resolve().parent / "data" / "differential_expression" +CONTRASTS = { + "stim_vs_ctrl": "conditionstim", + "D2_vs_D1": "donorD2", + "D2_vs_D4": "donorD2 - donorD4", +} +# The rows of limma's tables in limma.npz. +COLUMNS = ( + "log2_fold_change", + "average_log2_cpm", + "t", + "p_value", + "adjusted_p_value", + "b_statistic", + "standard_error", +) + + +def analyse() -> None: + """Test the fixture's contrasts by limma_voom and compare the T cells' with limma's. + + Raises: + AssertionError: If a table holds other features than limma's, or a value + differs from limma's by more than 1e-6. + """ + import numpy as np + + from cytepool.cube import from_cells + from cytepool.design import Design + from cytepool.differential_expression import limma_voom + + with np.load(DATA / "cells.npz") as data: + cells = {key: data[key] for key in data.files} + with np.load(DATA / "limma.npz") as data: + limma = {key: data[key] for key in data.files} + sample_table = { + key.removeprefix("sample_table "): values + for key, values in cells.items() + if key.startswith("sample_table ") + } + cube = from_cells( + cells["counts"], + samples=cells["samples"], + groups=cells["groups"], + sample_table=sample_table, + layers="counts", + ) + design = Design( + cube.samples, + "~ donor + condition", + levels={"condition": ["ctrl", "stim"]}, + contrasts=CONTRASTS, + ) + result = limma_voom(cube, design, contrast_standard_errors="limma") + tested = cube.features["feature_id"][limma["voom T keep"]].tolist() + for contrast in CONTRASTS: + table = result.table("T", contrast) + assert table["feature_id"].tolist() == tested, contrast + for row, column in enumerate(COLUMNS): + # tests/test_differential_expression.py holds these to 1e-10 of limma's; + # other processors and math libraries may round differently, and a broken + # build or installation differs by far more than 1e-6. + np.testing.assert_allclose( + table[column], + limma[f"voom T {contrast}"][row], + rtol=1e-6, + atol=0 if column.endswith("p_value") else 1e-6, + err_msg=f"{contrast}: {column}", + ) + + +def run_installed(version: str, from_cache: bool) -> None: + """Run the analysis with the installed package, and check how it compiled. + + Args: + version: The version of the wheel, which the package must report. + from_cache: Whether the routines must all load from the cache, which the + first run fills; otherwise they must all compile. + """ + import numba + import numpy as np + from numba.core.dispatcher import Dispatcher + + import cytepool + + location = Path(cytepool.__file__).resolve() + environment = Path(sys.prefix).resolve() + assert location.is_relative_to(environment), ( + f"cytepool was imported from {location}, not from {environment}" + ) + assert cytepool.__version__ == version, ( + f"the installed cytepool reports version {cytepool.__version__}, the wheel " + f"{version}" + ) + analyse() + routines = { + id(value): value + for name, module in list(sys.modules.items()) + if name.partition(".")[0] == "cytepool" + for value in vars(module).values() + if isinstance(value, Dispatcher) + }.values() + loaded = sum(sum(routine.stats.cache_hits.values()) for routine in routines) + compiled = sum(sum(routine.stats.cache_misses.values()) for routine in routines) + if from_cache: + assert loaded > 0 and compiled == 0, ( + f"{compiled} routines compiled again, {loaded} loaded from the cache" + ) + else: + assert compiled > 0 and loaded == 0, ( + f"{compiled} routines compiled, {loaded} loaded from a cache that " + "should be empty" + ) + print( + f"cytepool {version} with numpy {np.__version__} and numba " + f"{numba.__version__}: limma's tables; {compiled} routines compiled, " + f"{loaded} loaded from the cache" + ) + + +def smoke_test(wheel: Path) -> None: + """Install a wheel in a clean environment and run the analysis there twice.""" + version = wheel.name.split("-")[1] + with tempfile.TemporaryDirectory( + prefix="cytepool-wheel-", ignore_cleanup_errors=True + ) as scratch: + root = Path(scratch) + environment = root / "environment" + subprocess.run( + ["uv", "venv", "--python", sys.executable, str(environment)], check=True + ) + if os.name == "nt": + python = environment / "Scripts" / "python.exe" + else: + python = environment / "bin" / "python" + subprocess.run( + ["uv", "pip", "install", "--python", str(python), str(wheel)], check=True + ) + # A cache of its own, so that the first run compiles everything. + env = os.environ | {"NUMBA_CACHE_DIR": str(root / "numba-cache")} + for from_cache in (False, True): + command = [str(python), "-I", str(Path(__file__).resolve())] + command += ["--installed", version] + ["--from-cache"] * from_cache + subprocess.run(command, cwd=root, env=env, check=True) + print(f"{wheel.name} installs and runs") + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Install a wheel in a clean environment and run an analysis." + ) + parser.add_argument("wheel", type=Path, nargs="?", help="the wheel to test") + # The runs inside the environment. + parser.add_argument("--installed", metavar="VERSION", help=argparse.SUPPRESS) + parser.add_argument("--from-cache", action="store_true", help=argparse.SUPPRESS) + args = parser.parse_args() + if args.installed is not None: + run_installed(args.installed, args.from_cache) + elif args.wheel is not None: + smoke_test(args.wheel.resolve()) + else: + parser.error("give the wheel to test") + + +if __name__ == "__main__": + main() diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py new file mode 100644 index 0000000..6fe48a4 --- /dev/null +++ b/tests/test_release_artifacts.py @@ -0,0 +1,368 @@ +"""Tests for the checks that the release workflow runs before it publishes. + +The wheel and the source distribution are built once from this source tree, into a +temporary folder, by the command and the pinned build tools of the release workflow, +with the version that setuptools-scm gives the current commit (without a local part, +as a release tag gives none). ``validate_release_artifacts`` must accept them, and +refuse copies of them with each defect it looks for, naming that defect and nothing +else. The analysis of ``smoke_test_wheel`` runs here on this source tree, so that a +change that breaks it fails before a release does; the release workflow runs it with +the installed wheel. +""" + +import io +import os +import re +import shutil +import subprocess +import sys +import tarfile +from collections.abc import Callable +from pathlib import Path +from zipfile import ZipFile + +import pytest +from packaging.utils import parse_wheel_filename +from smoke_test_wheel import analyse +from validate_release_artifacts import ( + ROOT, + ReleaseArtifactError, + validate_release_artifacts, +) + +Change = Callable[[dict[str, bytes]], None] + + +@pytest.fixture(scope="module") +def release(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, Path, str]: + """The wheel and the source distribution of this tree, and their version.""" + uv = shutil.which("uv") + if uv is None: + pytest.skip("building the release files needs uv, which is not on PATH") + if shutil.which("git") is None or not (ROOT / ".git").exists(): + pytest.skip("building the release files needs git and a git checkout") + out = tmp_path_factory.mktemp("dist") + command = [ + uv, + "build", + "--quiet", + "--python", + sys.executable, + "--out-dir", + str(out), + ] + command += ["--build-constraints", str(ROOT / ".github" / "build-constraints.txt")] + # As the release workflow builds, but from a checkout that may have changes. + env = os.environ | { + "SETUPTOOLS_SCM_OVERRIDES_FOR_CYTEPOOL": '{local_scheme = "no-local-version"}' + } + build = subprocess.run( + [*command, str(ROOT)], env=env, capture_output=True, text=True + ) + assert build.returncode == 0, build.stderr + (wheel,) = out.glob("*.whl") + (sdist,) = out.glob("*.tar.gz") + return wheel, sdist, str(parse_wheel_filename(wheel.name)[1]) + + +def _files(artifact: Path) -> dict[str, bytes]: + """The files of a wheel or source distribution, by their path in the release.""" + if artifact.name.endswith(".whl"): + with ZipFile(artifact) as archive: + return {name: archive.read(name) for name in archive.namelist()} + folder = artifact.name.removesuffix(".tar.gz") + "/" + files = {} + with tarfile.open(artifact) as archive: + for member in archive.getmembers(): + if (data := archive.extractfile(member)) is not None: + files[member.name.removeprefix(folder)] = data.read() + return files + + +def _write(artifact: Path, files: dict[str, bytes]) -> Path: + if artifact.name.endswith(".whl"): + with ZipFile(artifact, "w") as archive: + for name, data in files.items(): + archive.writestr(name, data) + return artifact + folder = artifact.name.removesuffix(".tar.gz") + "/" + with tarfile.open(artifact, "w:gz", compresslevel=1) as archive: + for name, data in files.items(): + info = tarfile.TarInfo(folder + name) + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + return artifact + + +def _problems(artifacts: list[Path], release_tag: str) -> list[str]: + with pytest.raises(ReleaseArtifactError) as raised: + validate_release_artifacts(artifacts, release_tag) + return str(raised.value).splitlines() + + +def _add(name: str) -> Change: + return lambda files: files.update({name: b"VALUE = 1\n"}) + + +def _remove(name: str) -> Change: + def change(files: dict[str, bytes]) -> None: + del files[name] + + return change + + +def _stale_version_file(files: dict[str, bytes]) -> None: + text = files["cytepool/_version.py"].decode() + stale = re.sub( + r"(?m)^__version__ = version = .*$", "__version__ = version = '0.0.1'", text + ) + assert stale != text + files["cytepool/_version.py"] = stale.encode() + + +def _replace(name: str, old: str, new: str) -> Change: + """Replace text in the file ``name``, or in the one file in a folder of that name.""" + + def change(files: dict[str, bytes]) -> None: + if name in files: + path = name + else: + (path,) = [path for path in files if path.endswith(f"/{name}")] + text = files[path].decode() + assert old in text + files[path] = text.replace(old, new, 1).encode() + + return change + + +def _metadata(old: str, new: str) -> Change: + """Replace text in the core metadata of a wheel or a source distribution.""" + + def change(files: dict[str, bytes]) -> None: + _replace("PKG-INFO" if "PKG-INFO" in files else "METADATA", old, new)(files) + + return change + + +def test_the_release_files_of_this_tree_can_be_published( + release: tuple[Path, Path, str], +) -> None: + wheel, sdist, version = release + + assert validate_release_artifacts([wheel, sdist], version) == [ + f"{wheel.name}: cytepool {version}, ready to publish", + f"{sdist.name}: cytepool {version}, ready to publish", + ] + # What a release holds and leaves out, read from the files themselves. + wheel_files, sdist_files = _files(wheel), _files(sdist) + assert {name.split("/")[0] for name in wheel_files} == { + "cytepool", + f"cytepool-{version}.dist-info", + } + assert {name.split("/")[0] for name in sdist_files} == { + "LICENSE", + "MANIFEST.in", + "PKG-INFO", + "README.md", + "THIRD_PARTY_NOTICES.md", + "cytepool", + "cytepool.egg-info", + "pyproject.toml", + "setup.cfg", + } + for files in (wheel_files, sdist_files): + assert {"cytepool/_version.py", "cytepool/stats/voom.py"} <= files.keys() + + +@pytest.mark.parametrize( + ("artifact", "change", "expected"), + [ + pytest.param( + "wheel", + _add("tests/test_cube.py"), + ["tests/test_cube.py does not belong in a release"], + id="tests-in-the-wheel", + ), + pytest.param( + "sdist", + _add("docs/source/index.md"), + ["docs/source/index.md does not belong in a release"], + id="docs-in-the-sdist", + ), + pytest.param( + "wheel", + _remove("cytepool/stats/voom.py"), + ["cytepool/stats/voom.py is missing"], + id="a-module-missing", + ), + pytest.param( + "wheel", + _remove("cytepool/_version.py"), + ["cytepool/_version.py is missing"], + id="no-version-file", + ), + pytest.param( + "sdist", + _remove("LICENSE"), + ["LICENSE is missing"], + id="no-licence", + ), + pytest.param( + "sdist", + _stale_version_file, + ["cytepool/_version.py names version 0.0.1, expected {version}"], + id="a-stale-version-file", + ), + pytest.param( + "wheel", + _replace("cytepool/table.py", '"""', '"""Edited. '), + ["cytepool/table.py differs from the source tree"], + id="an-edited-module", + ), + pytest.param( + "wheel", + _replace("WHEEL", "Root-Is-Purelib: true", "Root-Is-Purelib: false"), + [ + "cytepool-{version}.dist-info/WHEEL does not describe a pure Python " + "wheel (Root-Is-Purelib: true, Tag: py3-none-any)" + ], + id="not-pure-python", + ), + pytest.param( + "wheel", + _metadata( + "Requires-Dist: numpy>=2.1\n", + "Requires-Dist: numpy>=2.1\nRequires-Dist: pandas>=2.2\n", + ), + [ + "requires pandas>=2.2, which pyproject.toml does not", + "requires numba, numpy, pandas without an extra; only numba and " + "numpy may be", + ], + id="another-requirement", + ), + pytest.param( + "sdist", + _metadata( + 'Provides-Extra: plot\nRequires-Dist: matplotlib>=3.9; extra == "plot"\n', + "", + ), + [ + 'does not require matplotlib>=3.9; extra == "plot", as pyproject.toml ' + "does", + "Provides-Extra is ['anndata', 'mudata', 'test'], expected " + "['anndata', 'mudata', 'plot', 'test']", + ], + id="an-extra-missing", + ), + pytest.param( + "wheel", + _metadata( + 'mudata!=0.4.0,>=0.3.1; python_version < "3.15" and extra == "test"', + 'mudata!=0.4.0,>=0.3.1; extra == "test"', + ), + [ + 'requires mudata!=0.4.0,>=0.3.1; extra == "test", which ' + "pyproject.toml does not", + 'does not require mudata!=0.4.0,>=0.3.1; python_version < "3.15" and ' + 'extra == "test", as pyproject.toml does', + ], + id="a-marker-missing", + ), + pytest.param( + "wheel", + _metadata("License-Expression: BSD-3-Clause", "License-Expression: MIT"), + ["License-Expression is MIT, expected BSD-3-Clause"], + id="another-licence", + ), + pytest.param( + "sdist", + _metadata("Requires-Python: >=3.13", "Requires-Python: >=3.12"), + ["Requires-Python is >=3.12, expected >=3.13"], + id="other-python-versions", + ), + pytest.param( + "wheel", + _metadata("**Status:**", "**State:**"), + ["the description is not README.md"], + id="another-description", + ), + ], +) +def test_a_defect_of_a_release_file_is_named( + release: tuple[Path, Path, str], + tmp_path: Path, + artifact: str, + change: Change, + expected: list[str], +) -> None: + wheel, sdist, version = release + original = wheel if artifact == "wheel" else sdist + files = _files(original) + change(files) + defective = _write(tmp_path / original.name, files) + artifacts = [defective, sdist] if artifact == "wheel" else [wheel, defective] + + assert _problems(artifacts, version) == [ + f"{original.name}: {problem.format(version=version)}" for problem in expected + ] + + +def test_the_version_must_be_that_of_the_release_tag( + release: tuple[Path, Path, str], +) -> None: + wheel, sdist, version = release + + assert _problems([wheel, sdist], "v99.0") == [ + f"{artifact.name}: {problem}" + for artifact in (wheel, sdist) + for problem in ( + f"the file name names version {version}, expected 99.0", + f"cytepool/_version.py names version {version}, expected 99.0", + f"Version is {version}, expected 99.0", + ) + ] + + +def test_a_release_is_one_pure_wheel_and_one_source_distribution( + release: tuple[Path, Path, str], tmp_path: Path +) -> None: + wheel, sdist, version = release + notes = tmp_path / "notes.txt" + notes.write_text("") + for_one_platform = tmp_path / wheel.name.replace( + "py3-none-any", "cp313-cp313-win_amd64" + ) + shutil.copy(wheel, for_one_platform) + + assert _problems([wheel, wheel, notes], version) == [ + "notes.txt is neither a wheel nor a source distribution", + "a release has one wheel, found 2", + "a release has one source distribution, found 0", + ] + assert _problems([for_one_platform, sdist], version) == [ + f"{for_one_platform.name}: is built for cp313-cp313-win_amd64, expected " + "py3-none-any" + ] + + +@pytest.mark.parametrize( + ("release_tag", "message"), + [ + ("v-next", "the release tag 'v-next' is not a version, such as 0.1.0"), + ( + "0.1.0+nygen", + "the release tag '0.1.0+nygen' has a local version part (+nygen), which " + "PyPI refuses", + ), + ], +) +def test_a_release_tag_must_be_a_version_without_a_local_part( + release_tag: str, message: str +) -> None: + with pytest.raises(ReleaseArtifactError, match=re.escape(message)): + validate_release_artifacts([], release_tag) + + +def test_the_analysis_of_the_wheel_smoke_test_gives_limmas_tables() -> None: + analyse() diff --git a/tests/validate_release_artifacts.py b/tests/validate_release_artifacts.py new file mode 100644 index 0000000..9ad2ca2 --- /dev/null +++ b/tests/validate_release_artifacts.py @@ -0,0 +1,368 @@ +"""Check the wheel and the source distribution of a release before they are published. + +The release workflow, ``.github/workflows/publish.yml``, runs this on what it built:: + + uv run --no-project --with packaging==26.3 \\ + python tests/validate_release_artifacts.py --release-tag 0.1.0 dist/* + +A release has one wheel, for every platform (``py3-none-any``), and one source +distribution. Both must name the distribution ``cytepool`` and the version of the +release tag, which has no local part, and declare what ``pyproject.toml`` declares: +the Python versions, the requirements and the extras (only numpy and numba are +required without an extra), the licence and its files, and ``README.md`` as the +description. Both must hold every module of the package as it is in the source tree, +and ``cytepool/_version.py`` naming the version. Besides those, the wheel holds its +metadata and the licence files, and the source distribution the files that build the +package and those that setuptools writes; any other file, such as one of ``tests/`` or +``docs/``, is refused. Every problem found is printed, and the exit status is then 1. +""" + +import argparse +import ast +import sys +import tarfile +import tomllib +from email.parser import Parser +from email.policy import compat32 +from pathlib import Path +from typing import Any +from zipfile import ZipFile + +from packaging.markers import Marker +from packaging.requirements import Requirement +from packaging.specifiers import SpecifierSet +from packaging.tags import Tag +from packaging.utils import ( + canonicalize_name, + parse_sdist_filename, + parse_wheel_filename, +) +from packaging.version import InvalidVersion, Version + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = "cytepool" +VERSION_FILE = f"{PACKAGE}/_version.py" +# The package depends only on numpy and numba. +REQUIRED = ["numba", "numpy"] +PURE_PYTHON = Tag("py3", "none", "any") +# The files of the source tree in a source distribution, besides the package's modules +# and the licence files. +BUILD_FILES = ("MANIFEST.in", "README.md", "pyproject.toml") +# What setuptools writes into a source distribution; a name ending in "/" is a folder. +SDIST_GENERATED = ("PKG-INFO", "setup.cfg", f"{PACKAGE}.egg-info/") + + +class ReleaseArtifactError(ValueError): + """The files of a release cannot be published as they are.""" + + +def release_version(release_tag: str) -> Version: + """Return the version that a release tag names. + + Raises: + ReleaseArtifactError: If the tag is not a version, or has a local part, which + PyPI refuses. + """ + try: + version = Version(release_tag) + except InvalidVersion: + raise ReleaseArtifactError( + f"the release tag {release_tag!r} is not a version, such as 0.1.0 or " + "0.2.0rc1" + ) from None + if version.local is not None: + raise ReleaseArtifactError( + f"the release tag {release_tag!r} has a local version part " + f"(+{version.local}), which PyPI refuses" + ) + return version + + +def validate_release_artifacts( + artifacts: list[Path], release_tag: str, root: Path = ROOT +) -> list[str]: + """Check the files of a release against its tag and the source tree they come from. + + Args: + artifacts: The files to publish: one wheel and one source distribution. + release_tag: The tag of the release, which names its version. + root: The source tree that the files were built from. + + Returns: + A line for each file, saying that it can be published. + + Raises: + ReleaseArtifactError: Naming every problem found, one per line. + """ + version = release_version(release_tag) + pyproject = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8")) + project = pyproject["project"] + wheels = [path for path in artifacts if path.name.endswith(".whl")] + sdists = [path for path in artifacts if path.name.endswith(".tar.gz")] + problems = [ + f"{path.name} is neither a wheel nor a source distribution" + for path in artifacts + if path not in wheels and path not in sdists + ] + for kind, found in (("wheel", wheels), ("source distribution", sdists)): + if len(found) != 1: + problems.append(f"a release has one {kind}, found {len(found)}") + for path in wheels: + for problem in _wheel_problems(path, version, project, root): + problems.append(f"{path.name}: {problem}") + for path in sdists: + for problem in _sdist_problems(path, version, project, root): + problems.append(f"{path.name}: {problem}") + if problems: + raise ReleaseArtifactError("\n".join(problems)) + return [f"{path.name}: {PACKAGE} {version}, ready to publish" for path in artifacts] + + +def _wheel_problems( + path: Path, version: Version, project: dict[str, Any], root: Path +) -> list[str]: + try: + name, file_version, _, tags = parse_wheel_filename(path.name) + except ValueError as error: + return [str(error)] + problems = _file_name_problems(name, file_version, version) + if tags != {PURE_PYTHON}: + built_for = ", ".join(sorted(map(str, tags))) + problems.append(f"is built for {built_for}, expected {PURE_PYTHON}") + with ZipFile(path) as archive: + files = { + name: archive.read(name) + for name in archive.namelist() + if not name.endswith("/") + } + folders = {name.split("/")[0] for name in files} + dist_infos = sorted(folder for folder in folders if folder.endswith(".dist-info")) + if len(dist_infos) != 1: + return problems + [f"has {len(dist_infos)} .dist-info folders, expected one"] + dist_info = dist_infos[0] + sources = _modules(root) | { + f"{dist_info}/licenses/{name}": source + for name, source in _licence_files(project, root) + } + problems += _content_problems(files, sources, (f"{dist_info}/",), version) + wheel = Parser(policy=compat32).parsestr( + files.get(f"{dist_info}/WHEEL", b"").decode("utf-8") + ) + if wheel.get("Root-Is-Purelib") != "true" or wheel.get_all("Tag") != [ + str(PURE_PYTHON) + ]: + problems.append( + f"{dist_info}/WHEEL does not describe a pure Python wheel " + f"(Root-Is-Purelib: true, Tag: {PURE_PYTHON})" + ) + metadata = files.get(f"{dist_info}/METADATA") + if metadata is None: + return problems + [f"{dist_info}/METADATA is missing"] + return problems + _metadata_problems(metadata, version, project, root) + + +def _sdist_problems( + path: Path, version: Version, project: dict[str, Any], root: Path +) -> list[str]: + try: + name, file_version = parse_sdist_filename(path.name) + except ValueError as error: + return [str(error)] + problems = _file_name_problems(name, file_version, version) + folder = path.name.removesuffix(".tar.gz") + "/" + files = {} + with tarfile.open(path, "r:gz") as archive: + for member in archive.getmembers(): + if member.isdir(): + continue + data = archive.extractfile(member) if member.isfile() else None + if data is None or not member.name.startswith(folder): + problems.append(f"{member.name} is not a file in {folder}") + continue + files[member.name.removeprefix(folder)] = data.read() + sources = _modules(root) | dict(_licence_files(project, root)) + sources |= {name: root / name for name in BUILD_FILES} + problems += _content_problems(files, sources, SDIST_GENERATED, version) + metadata = files.get("PKG-INFO") + if metadata is None: + return problems + ["PKG-INFO is missing"] + return problems + _metadata_problems(metadata, version, project, root) + + +def _file_name_problems( + name: str, file_version: Version, version: Version +) -> list[str]: + problems = [] + if name != PACKAGE: + problems.append(f"the file name names {name}, expected {PACKAGE}") + if file_version != version: + problems.append( + f"the file name names version {file_version}, expected {version}" + ) + return problems + + +def _modules(root: Path) -> dict[str, Path]: + """The package's modules in the source tree, by their path in an artifact.""" + package = root / PACKAGE + return { + f"{PACKAGE}/{path.relative_to(package).as_posix()}": path + for path in sorted(package.rglob("*.py")) + if path.relative_to(package).as_posix() != "_version.py" + } + + +def _licence_files(project: dict[str, Any], root: Path) -> list[tuple[str, Path]]: + """The licence files of pyproject.toml, with their path in the source tree.""" + return sorted( + (path.relative_to(root).as_posix(), path) + for pattern in project["license-files"] + for path in root.glob(pattern) + ) + + +def _content_problems( + files: dict[str, bytes], + sources: dict[str, Path], + generated: tuple[str, ...], + version: Version, +) -> list[str]: + """Problems with the files of an artifact. + + Each file must be a copy of its source in the source tree, the version file, or a + file that the build writes (``generated``); every source and the version file must + be there. + """ + problems = [] + for name, data in sorted(files.items()): + if name in sources: + if data != sources[name].read_bytes(): + problems.append(f"{name} differs from the source tree") + elif name == VERSION_FILE: + found = _assigned_version(data) + try: + matches = found is not None and Version(found) == version + except InvalidVersion: + matches = False + if not matches: + named = "no version" if found is None else f"version {found}" + problems.append(f"{name} names {named}, expected {version}") + elif not any( + name == entry or (entry.endswith("/") and name.startswith(entry)) + for entry in generated + ): + problems.append(f"{name} does not belong in a release") + missing = (sources.keys() | {VERSION_FILE}) - files.keys() + return problems + [f"{name} is missing" for name in sorted(missing)] + + +def _assigned_version(source: bytes) -> str | None: + """The version that a version file assigns to ``__version__``.""" + for node in ast.parse(source).body: + if ( + isinstance(node, ast.Assign) + and isinstance(node.value, ast.Constant) + and any( + isinstance(target, ast.Name) and target.id == "__version__" + for target in node.targets + ) + ): + return str(node.value.value) + return None + + +def _metadata_problems( + data: bytes, version: Version, project: dict[str, Any], root: Path +) -> list[str]: + """Problems with an artifact's core metadata, against pyproject.toml.""" + metadata = Parser(policy=compat32).parsestr(data.decode("utf-8")) + problems = [] + + def check(field: str, found: object, expected: object) -> None: + if found != expected: + problems.append(f"{field} is {found}, expected {expected}") + + check("Name", canonicalize_name(metadata.get("Name", "")), PACKAGE) + try: + check("Version", Version(metadata.get("Version", "")), version) + except InvalidVersion: + problems.append(f"Version is {metadata.get('Version')}, which is not a version") + check( + "Requires-Python", + SpecifierSet(metadata.get("Requires-Python", "")), + SpecifierSet(project["requires-python"]), + ) + check("License-Expression", metadata.get("License-Expression"), project["license"]) + check( + "License-File", + sorted(metadata.get_all("License-File", [])), + [name for name, _ in _licence_files(project, root)], + ) + readme = project["readme"] + check( + "Description-Content-Type", + metadata.get("Description-Content-Type"), + readme["content-type"], + ) + if metadata.get_payload() != (root / readme["file"]).read_text(encoding="utf-8"): + problems.append(f"the description is not {readme['file']}") + requirements = [Requirement(line) for line in metadata.get_all("Requires-Dist", [])] + found = {str(requirement) for requirement in requirements} + declared = _declared_requirements(project) + for requirement in sorted(found - declared): + problems.append(f"requires {requirement}, which pyproject.toml does not") + for requirement in sorted(declared - found): + problems.append(f"does not require {requirement}, as pyproject.toml does") + required = sorted( + {canonicalize_name(r.name) for r in requirements if r.marker is None} + ) + if required != REQUIRED: + problems.append( + f"requires {', '.join(required)} without an extra; only " + f"{' and '.join(REQUIRED)} may be" + ) + check( + "Provides-Extra", + sorted( + canonicalize_name(extra) for extra in metadata.get_all("Provides-Extra", []) + ), + sorted(canonicalize_name(extra) for extra in project["optional-dependencies"]), + ) + return problems + + +def _declared_requirements(project: dict[str, Any]) -> set[str]: + """The requirements of pyproject.toml, written as core metadata writes them.""" + declared = {str(Requirement(text)) for text in project["dependencies"]} + for extra, texts in project["optional-dependencies"].items(): + for text in texts: + requirement = Requirement(text) + condition = f'extra == "{canonicalize_name(extra)}"' + marker = requirement.marker + requirement.marker = Marker( + f"({marker}) and {condition}" if marker else condition + ) + declared.add(str(requirement)) + return declared + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Check the wheel and the source distribution of a release." + ) + parser.add_argument( + "--release-tag", required=True, help="the tag of the release, such as 0.1.0" + ) + parser.add_argument( + "artifacts", nargs="+", type=Path, help="the wheel and the source distribution" + ) + args = parser.parse_args() + try: + for line in validate_release_artifacts(args.artifacts, args.release_tag): + print(line) + except ReleaseArtifactError as error: + sys.exit(f"The release cannot be published:\n{error}") + + +if __name__ == "__main__": + main()