diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml new file mode 100644 index 00000000..9b095bbc --- /dev/null +++ b/.github/workflows/downstream.yml @@ -0,0 +1,52 @@ +# Downstream checks run on pull requests to main when the head branch name +# contains "downstream", or any time via workflow_dispatch. For dispatch-only +# runs: Actions → downstream → Run workflow → pick a branch under "Use workflow from". +# +# The workflow must exist on the default branch for it to appear under the +# upstream repo's Actions tab; until the workflow is merged, use the fork's +# Actions (or dispatch from a branch that contains this file). +name: downstream + +on: + workflow_dispatch: + pull_request: + branches: + - main + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + recipe: + name: downstream (${{ matrix.recipe }}) + runs-on: ubuntu-latest + timeout-minutes: 120 + if: >- + github.event_name == 'workflow_dispatch' + || (github.event_name == 'pull_request' && contains(github.head_ref, 'downstream')) + strategy: + fail-fast: false + matrix: + recipe: + - conda + - datasette + - devpi + - hatch + - pytest + - python-lsp-server + - tox + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + allow-prereleases: true + - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 + - name: Run downstream recipe + run: uv run downstream/run_downstream.py "${{ matrix.recipe }}" diff --git a/RELEASING.rst b/RELEASING.rst index 3d6ba16c..d87ace73 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -3,7 +3,8 @@ Release Procedure #. Dependening on the magnitude of the changes in the release, consider testing some of the large downstream users of pluggy against the upcoming release. - You can do so using the scripts in the ``downstream/`` directory. + You can do so with ``uv run downstream/run_downstream.py`` (option + ``--list`` lists recipes). #. From a clean work tree, execute:: diff --git a/downstream/.gitignore b/downstream/.gitignore index 0dc1814e..bdd5becb 100644 --- a/downstream/.gitignore +++ b/downstream/.gitignore @@ -3,4 +3,5 @@ /devpi/ /hatch/ /pytest/ +/python-lsp-server/ /tox/ diff --git a/downstream/README.md b/downstream/README.md index ff420e7d..30375e5b 100644 --- a/downstream/README.md +++ b/downstream/README.md @@ -1,2 +1,29 @@ -This directory contains scripts for testing some downstream projects -against your current pluggy worktree. +This directory contains tooling for testing some downstream projects against +your current pluggy checkout. + +Each project is described by a TOML recipe in `recipes/` with three sections: + +1. **`[git]`** — repository URL, local directory name (`into`), optional `shallow` + (default: shallow clone). +2. **`[environment]`** — distinguished by structure (no `kind` key needed): + - **uv-venv** (has `editables`): creates `uv venv`, installs via + `uv pip install`. `editables` are passed as `-e` args. Optional + `groups` and `packages` for extra dependencies. + - **script** (has `run`): delegates bootstrap, install, and test to a + bash script (path relative to `downstream/`). No `[[test]]` steps. +3. **`[[test]]`** (uv-venv only) — one or more `argv` arrays. The driver sets + **`VIRTUAL_ENV`** and prepends the venv's `bin` to **`PATH`**, so test + commands can use bare names like `pytest`. Optional **`env`** table sets + extra environment variables; an empty string removes the variable (e.g. + `env = { CI = "" }` to unset `CI`). + Install only: `--only-install`. Skip install: `--skip-install`. + +Run the driver (PEP 723 in `run_downstream.py`): + +```bash +uv run downstream/run_downstream.py --list +uv run downstream/run_downstream.py pytest +uv run downstream/run_downstream.py pytest --skip-install +``` + +Requirements: Python 3.11+ for the driver, `git`, and `uv`. diff --git a/downstream/conda.sh b/downstream/conda.sh deleted file mode 100755 index 685d08d4..00000000 --- a/downstream/conda.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -set -eux -o pipefail -if [[ ! -d conda ]]; then - git clone https://github.com/conda/conda -fi -pushd conda && trap popd EXIT -git pull -set +eu -source dev/start -set -eu -pip install -e ../../ -pytest -m "not integration and not installed" diff --git a/downstream/datasette.sh b/downstream/datasette.sh deleted file mode 100755 index 7d3c5586..00000000 --- a/downstream/datasette.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -eux -o pipefail -if [[ ! -d datasette ]]; then - git clone https://github.com/simonw/datasette -fi -pushd datasette && trap popd EXIT -git pull -python -m venv venv -venv/bin/pip install -e .[test] -e ../.. -venv/bin/pytest diff --git a/downstream/devpi.sh b/downstream/devpi.sh deleted file mode 100755 index 7ef09c8d..00000000 --- a/downstream/devpi.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash -set -eux -o pipefail -if [[ ! -d devpi ]]; then - git clone https://github.com/devpi/devpi -fi -pushd devpi && trap popd EXIT -git pull -python -m venv venv -venv/bin/pip install -r dev-requirements.txt -e ../.. -venv/bin/pytest common -venv/bin/pytest server -venv/bin/pytest client -venv/bin/pytest web diff --git a/downstream/hatch.sh b/downstream/hatch.sh deleted file mode 100755 index 933e0f63..00000000 --- a/downstream/hatch.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -eux -o pipefail -if [[ ! -d hatch ]]; then - git clone https://github.com/pypa/hatch -fi -pushd hatch && trap popd EXIT -git pull -python -m venv venv -venv/bin/pip install -e . -e ./backend -e ../.. -venv/bin/hatch run dev diff --git a/downstream/pytest.sh b/downstream/pytest.sh deleted file mode 100755 index 5afc5612..00000000 --- a/downstream/pytest.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -set -eux -o pipefail -if [[ ! -d pytest ]]; then - git clone https://github.com/pytest-dev/pytest -fi -pushd pytest && trap popd EXIT -git pull -python -m venv venv -venv/bin/pip install -e .[testing] -e ../.. -venv/bin/pytest diff --git a/downstream/python-lsp-server.sh b/downstream/python-lsp-server.sh deleted file mode 100644 index 0828faed..00000000 --- a/downstream/python-lsp-server.sh +++ /dev/null @@ -1,31 +0,0 @@ -set -eux -o pipefail -if [[ ! -d python-lsp-server ]]; then - git clone https://github.com/python-lsp/python-lsp-server.git -fi - -pushd python-lsp-server -trap popd EXIT - -git pull - -python -m venv venv - -if [[ "$OS" == "Windows_NT" ]]; then - VENV_PYTHON="venv/Scripts/python" - VENV_PYTEST="venv/Scripts/pytest" -else - VENV_PYTHON="venv/bin/python" - VENV_PYTEST="venv/bin/pytest" -fi - -# upgrade pip safely -"$VENV_PYTHON" -m pip install -U pip - -# install python-lsp-server test deps -"$VENV_PYTHON" -m pip install -e .[test] - -# install local pluggy -"$VENV_PYTHON" -m pip install -e .. - -# run tests -"$VENV_PYTEST" || true diff --git a/downstream/recipes/conda.bash b/downstream/recipes/conda.bash new file mode 100755 index 00000000..99a731f4 --- /dev/null +++ b/downstream/recipes/conda.bash @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Bootstrap, install, and test conda against the local pluggy checkout. +# Called from run_downstream.py via the conda recipe. +set -eu + +cd "$(dirname "$0")/../conda" + +# dev/start needs relaxed error handling during bootstrap. +set +eu +source dev/start +set -eu + +# Install pluggy editable from the parent checkout. +pip install -e ../.. + +# Mirror conda's own CI condarc-defaults so tests that create temporary +# environments can resolve packages. +conda config --add channels defaults + +pytest \ + -m 'not integration and not installed' \ + --deselect=tests/cli/test_main_export.py::test_export_from_history_format diff --git a/downstream/recipes/conda.toml b/downstream/recipes/conda.toml new file mode 100644 index 00000000..326bd15e --- /dev/null +++ b/downstream/recipes/conda.toml @@ -0,0 +1,7 @@ +[git] +url = "https://github.com/conda/conda" +into = "conda" +shallow = false + +[environment] +run = "recipes/conda.bash" diff --git a/downstream/recipes/datasette.toml b/downstream/recipes/datasette.toml new file mode 100644 index 00000000..30f19261 --- /dev/null +++ b/downstream/recipes/datasette.toml @@ -0,0 +1,10 @@ +[git] +url = "https://github.com/simonw/datasette" +into = "datasette" + +[environment] +editables = [".", "../.."] +groups = ["dev"] + +[[test]] +argv = ["pytest"] diff --git a/downstream/recipes/devpi.toml b/downstream/recipes/devpi.toml new file mode 100644 index 00000000..8ba2efcf --- /dev/null +++ b/downstream/recipes/devpi.toml @@ -0,0 +1,21 @@ +[git] +url = "https://github.com/devpi/devpi" +into = "devpi" + +[environment] +editables = ["common", "server", "client", "web", "../.."] +groups = ["pytest"] + +[[test]] +argv = ["pytest", "common"] + +[[test]] +argv = ["pytest", "server"] + +[[test]] +# Ignore test_upload: upstream conftest bug compares code=[200,200,200] +# against integers, causing TypeError on every upload-with-docs test. +argv = ["pytest", "client", "--ignore=client/testing/test_upload.py"] + +[[test]] +argv = ["pytest", "web"] diff --git a/downstream/recipes/hatch.toml b/downstream/recipes/hatch.toml new file mode 100644 index 00000000..d0c2deb8 --- /dev/null +++ b/downstream/recipes/hatch.toml @@ -0,0 +1,18 @@ +[git] +url = "https://github.com/pypa/hatch" +into = "hatch" + +[environment] +editables = [".", "./backend", "../.."] +packages = [ + "pytest", + "pytest-mock", + "pytest-xdist", + "filelock", + "flit-core", + "trustme", + "editables", +] + +[[test]] +argv = ["pytest", "tests/backend"] diff --git a/downstream/recipes/pytest.toml b/downstream/recipes/pytest.toml new file mode 100644 index 00000000..69664024 --- /dev/null +++ b/downstream/recipes/pytest.toml @@ -0,0 +1,10 @@ +[git] +url = "https://github.com/pytest-dev/pytest" +into = "pytest" +shallow = false + +[environment] +editables = [".[dev]", "../.."] + +[[test]] +argv = ["pytest"] diff --git a/downstream/recipes/python-lsp-server.toml b/downstream/recipes/python-lsp-server.toml new file mode 100644 index 00000000..da23579b --- /dev/null +++ b/downstream/recipes/python-lsp-server.toml @@ -0,0 +1,15 @@ +[git] +url = "https://github.com/python-lsp/python-lsp-server.git" +into = "python-lsp-server" + +[environment] +editables = [".[all,test]", "../.."] + +[[test]] +# Deselect two jedi-environment tests that require /tmp/pyenv/ fixture +# not provided by the default test environment. +argv = [ + "pytest", + "--deselect=test/plugins/test_completion.py::test_jedi_completion_environment", + "--deselect=test/plugins/test_symbols.py::test_symbols_all_scopes_with_jedi_environment", +] diff --git a/downstream/recipes/tox.toml b/downstream/recipes/tox.toml new file mode 100644 index 00000000..a567c32a --- /dev/null +++ b/downstream/recipes/tox.toml @@ -0,0 +1,16 @@ +[git] +url = "https://github.com/tox-dev/tox" +into = "tox" +shallow = false + +[environment] +editables = [".[completion]", "../.."] +groups = ["test"] + +[[test]] +argv = ["pytest"] +# tox's is_ci() checks presence of CI, value of GITHUB_ACTIONS, and +# other env vars. Empty string = remove from environment, non-empty +# overrides value. Both must be gone for list_dependencies to default +# to False and test expectations (no freeze steps, etc.) to hold. +env = { CI = "", GITHUB_ACTIONS = "" } diff --git a/downstream/run_downstream.py b/downstream/run_downstream.py new file mode 100644 index 00000000..dc2b6498 --- /dev/null +++ b/downstream/run_downstream.py @@ -0,0 +1,297 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = [ +# "pydantic>=2.7", +# ] +# /// +from __future__ import annotations + +import argparse +from collections.abc import Mapping +from collections.abc import Sequence +import os +from pathlib import Path +import shlex +import subprocess +import sys +from typing import Annotated + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import field_validator +from pydantic import model_validator +from pydantic import ValidationError +import tomllib + + +class GitConfig(BaseModel): + model_config = ConfigDict(extra="forbid") + + url: str + into: str + shallow: bool = True + + +class EnvironmentUv(BaseModel): + """uv-venv: create venv, install editables + optional groups/packages.""" + + model_config = ConfigDict(extra="forbid") + + editables: list[str] = Field(min_length=1) + groups: list[str] = Field(default_factory=list) + packages: list[str] = Field(default_factory=list) + + @field_validator("editables") + @classmethod + def editables_non_empty_strings(cls, v: list[str]) -> list[str]: + for i, s in enumerate(v): + if not s.strip(): + msg = f"editables[{i}] must be a non-empty string" + raise ValueError(msg) + return v + + +class EnvironmentScript(BaseModel): + """script: delegate everything to a bash script.""" + + model_config = ConfigDict(extra="forbid") + + run: str + + +Environment = Annotated[ + EnvironmentUv | EnvironmentScript, + Field(union_mode="left_to_right"), +] + + +class TestStep(BaseModel): + model_config = ConfigDict(extra="forbid") + + argv: list[str] = Field(min_length=1) + env: dict[str, str] = Field(default_factory=dict) + + +class RecipeFile(BaseModel): + model_config = ConfigDict(extra="forbid") + + git: GitConfig + environment: Environment + test: list[TestStep] = Field(default_factory=list) + + @model_validator(mode="after") + def script_has_no_test_steps(self) -> RecipeFile: + if isinstance(self.environment, EnvironmentScript) and self.test: + msg = "script environments handle testing; [[test]] must be empty" + raise ValueError(msg) + return self + + +DOWNSTREAM_DIR = Path(__file__).resolve().parent +RECIPES_DIR = DOWNSTREAM_DIR / "recipes" + +VENV_DIRNAME = "venv" + + +def venv_bin_dir(venv_home: Path) -> Path: + root = venv_home.resolve() + posix = root / "bin" + if posix.is_dir(): + return posix + return root / "Scripts" + + +def venv_python(venv_home: Path) -> Path: + d = venv_bin_dir(venv_home) + for name in ("python", "python3", "python.exe"): + candidate = d / name + if candidate.is_file(): + return candidate + return d / "python" + + +def subprocess_env( + *, + extra: Mapping[str, str] | None, + venv_home: Path | None, +) -> dict[str, str]: + env = {**os.environ, **(extra or {})} + # Empty-string values mean "remove from environment". + for key, val in env.items(): + if val == "": + del env[key] + if venv_home is None: + return env + root = venv_home.resolve() + bindir = venv_bin_dir(root) + env["VIRTUAL_ENV"] = str(root) + env["PATH"] = str(bindir) + os.pathsep + env.get("PATH", "") + return env + + +def echo_cmd(argv: Sequence[str], *, cwd: Path) -> None: + display = shlex.join(argv) + print(f"+ cd {cwd.as_posix()} && {display}", flush=True) + + +def run_cmd( + argv: Sequence[str], + *, + cwd: Path, + env: Mapping[str, str] | None = None, + venv_home: Path | None = None, +) -> None: + echo_cmd(argv, cwd=cwd) + merged_env = subprocess_env(extra=env, venv_home=venv_home) + result = subprocess.run( + list(argv), + cwd=cwd, + env=merged_env, + check=False, + ) + if result.returncode != 0: + sys.exit(result.returncode) + + +def git_clone_or_pull(*, dest: Path, url: str, shallow: bool) -> None: + if dest.is_dir(): + run_cmd(["git", "-C", str(dest), "pull", "--ff-only"], cwd=dest.parent) + return + args = ["git", "clone"] + if shallow: + args.extend(["--depth", "1"]) + args.extend([url, str(dest)]) + run_cmd(args, cwd=dest.parent) + + +def ensure_uv_venv(root: Path) -> None: + cfg = root / VENV_DIRNAME / "pyvenv.cfg" + if cfg.is_file(): + return + run_cmd(["uv", "venv", VENV_DIRNAME], cwd=root) + + +def format_validation_error(path_name: str, err: ValidationError) -> str: + lines = [f"{path_name}: recipe validation failed"] + for e in err.errors(): + loc = ".".join(str(x) for x in e["loc"]) + msg = e["msg"] + lines.append(f" {loc}: {msg}") + return "\n".join(lines) + + +def load_recipe(name: str) -> RecipeFile: + path = RECIPES_DIR / f"{name}.toml" + if not path.is_file(): + available = ", ".join(sorted(p.stem for p in RECIPES_DIR.glob("*.toml"))) + print(f"Unknown downstream {name!r}. Available: {available}", file=sys.stderr) + sys.exit(2) + with path.open("rb") as fh: + data = tomllib.load(fh) + try: + return RecipeFile.model_validate(data) + except ValidationError as e: + print(format_validation_error(path.name, e), file=sys.stderr) + sys.exit(2) + + +def build_uv_install_argv(*, venv_home: Path, env: EnvironmentUv) -> list[str]: + py = str(venv_python(venv_home)) + args = ["uv", "pip", "install", "--python", py] + for g in env.groups: + args.extend(["--group", g]) + for spec in env.editables: + args.extend(["-e", spec]) + for pkg in env.packages: + args.append(pkg) + return args + + +def run_recipe( + name: str, + *, + skip_install: bool = False, + only_install: bool = False, +) -> None: + recipe = load_recipe(name) + dest = DOWNSTREAM_DIR / recipe.git.into + git_clone_or_pull(dest=dest, url=recipe.git.url, shallow=recipe.git.shallow) + + profile = recipe.environment + + if isinstance(profile, EnvironmentScript): + script = DOWNSTREAM_DIR / profile.run + run_cmd(["bash", str(script)], cwd=DOWNSTREAM_DIR) + return + + # uv-venv path + ensure_uv_venv(dest) + venv_home = dest / VENV_DIRNAME + + if not skip_install: + argv_i = build_uv_install_argv(venv_home=venv_home, env=profile) + run_cmd(argv_i, cwd=dest, venv_home=venv_home) + + if not only_install: + for step in recipe.test: + run_cmd( + step.argv, + cwd=dest, + env=step.env or None, + venv_home=venv_home, + ) + + +def list_recipes() -> None: + names = sorted(p.stem for p in RECIPES_DIR.glob("*.toml")) + for n in names: + print(n) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Clone or update a downstream project and run its check recipe.", + ) + parser.add_argument( + "downstream", + nargs="?", + help="Recipe name (see *.toml in downstream/recipes/)", + ) + parser.add_argument( + "--list", + action="store_true", + help="Print available recipe names and exit.", + ) + parser.add_argument( + "--skip-install", + action="store_true", + help="Run environment + tests only (reuse existing installs).", + ) + parser.add_argument( + "--only-install", + action="store_true", + help="Run environment + install phases only (skip tests).", + ) + return parser + + +def main(argv: list[str] | None = None) -> None: + parser = build_parser() + args = parser.parse_args(argv if argv is not None else sys.argv[1:]) + if args.list: + list_recipes() + return + if not args.downstream: + parser.error("downstream recipe name is required (or use --list)") + if args.only_install and args.skip_install: + parser.error("--only-install and --skip-install are mutually exclusive") + run_recipe( + args.downstream, + skip_install=args.skip_install, + only_install=args.only_install, + ) + + +if __name__ == "__main__": + main() diff --git a/downstream/tox.sh b/downstream/tox.sh deleted file mode 100755 index 79e12dfa..00000000 --- a/downstream/tox.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/usr/bin/env bash -set -eux -o pipefail -if [[ ! -d tox ]]; then - git clone https://github.com/tox-dev/tox -fi -pushd tox && trap popd EXIT -python -m venv venv -venv/bin/pip install -e .[testing] -e ../.. -venv/bin/pytest