From 05279db2d02b0ce76069402d283ecfa58abba093 Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Sat, 16 May 2026 20:46:47 +0200 Subject: [PATCH 1/2] ci: Multiple random runs --- .github/workflows/random_ci_pytest.yml | 39 ++++++++++--- utils/generate_random_versions.py | 78 ++++++++++++++++++++++---- 2 files changed, 98 insertions(+), 19 deletions(-) diff --git a/.github/workflows/random_ci_pytest.yml b/.github/workflows/random_ci_pytest.yml index f50580afc1..11f07100f7 100644 --- a/.github/workflows/random_ci_pytest.yml +++ b/.github/workflows/random_ci_pytest.yml @@ -6,31 +6,54 @@ on: env: PY_COLORS: 1 PYTEST_ADDOPTS: "--numprocesses=logical" + N_RUNS: 5 permissions: contents: read jobs: + generate: + runs-on: ubuntu-latest + outputs: + combos: ${{ steps.gen.outputs.combos }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.10" + - id: gen + name: generate-random-versions + run: | + python utils/generate_random_versions.py --num "$N_RUNS" --output combos.json + echo "combos=$(cat combos.json)" >> "$GITHUB_OUTPUT" + tox: + needs: generate strategy: + fail-fast: false matrix: - python-version: ["3.10"] - os: [ubuntu-latest] - - runs-on: ${{ matrix.os }} + combo: ${{ fromJson(needs.generate.outputs.combos) }} + runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: - python-version: ${{ matrix.python-version }} + python-version: "3.10" - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: enable-cache: "true" - cache-suffix: pytest-random-ci-${{ matrix.python-version }} + cache-suffix: pytest-random-ci-3.10 cache-dependency-glob: "pyproject.toml" - - name: generate-random-versions - run: python utils/generate_random_versions.py + - name: write-requirements + run: | + cat > random-requirements.txt < tuple[tuple[str, str, str, str], ...]: + return tuple( + (pd, np, pl, pa) + for pd, np in PANDAS_AND_NUMPY_VERSION + for pl in POLARS_VERSION + for pa in PYARROW_VERSION + ) + + +def sample_distinct(n: int) -> list[dict[str, str]]: + pool = all_combos() + if n > len(pool): + msg = f"Requested {n} distinct combos but only {len(pool)} exist." + raise ValueError(msg) + picks = random.sample(pool, n) + return [ + {"pandas": pd, "numpy": np, "polars": pl, "pyarrow": pa} + for pd, np, pl, pa in picks + ] + + +def to_requirements(combo: dict[str, str]) -> str: + return ( + f"numpy=={combo['numpy']}\n" + f"pandas=={combo['pandas']}\n" + f"polars=={combo['polars']}\n" + f"pyarrow=={combo['pyarrow']}\n" + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("-n", "--num", type=int, default=1) + parser.add_argument( + "-o", + "--output", + type=Path, + required=True, + help=( + "Path to write to. A `.json` extension writes a JSON array of combos; " + "any other extension writes a requirements.txt-style file (requires n=1)." + ), + ) + args = parser.parse_args() + + combos = sample_distinct(args.num) + + if args.output.suffix == ".json": + args.output.write_text(json.dumps(combos), "utf-8") + return + + if args.num != 1: + msg = f"Non-JSON output ({args.output.suffix or 'no extension'}) requires --num=1" + raise ValueError(msg) + args.output.write_text(to_requirements(combos[0]), "utf-8") + + +if __name__ == "__main__": + main() From 5895e5a2c73e6a689b5893706d3cb0c4a609755b Mon Sep 17 00:00:00 2001 From: FBruzzesi Date: Sat, 16 May 2026 21:33:15 +0200 Subject: [PATCH 2/2] all different --- utils/generate_random_versions.py | 43 +++++++++++++++++-------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/utils/generate_random_versions.py b/utils/generate_random_versions.py index 14b9481132..8547f3ba17 100644 --- a/utils/generate_random_versions.py +++ b/utils/generate_random_versions.py @@ -54,28 +54,26 @@ ) -def all_combos() -> tuple[tuple[str, str, str, str], ...]: - return tuple( - (pd, np, pl, pa) - for pd, np in PANDAS_AND_NUMPY_VERSION - for pl in POLARS_VERSION - for pa in PYARROW_VERSION - ) - - def sample_distinct(n: int) -> list[dict[str, str]]: - pool = all_combos() - if n > len(pool): - msg = f"Requested {n} distinct combos but only {len(pool)} exist." + """Return `n` combos where no version of any single library is reused.""" + n_max = min(len(PANDAS_AND_NUMPY_VERSION), len(POLARS_VERSION), len(PYARROW_VERSION)) + if n > n_max: + msg = ( + f"Requested {n} combos but at most {n_max} are possible " + "without reusing a version of any single library." + ) raise ValueError(msg) - picks = random.sample(pool, n) + pandas_numpy = random.sample(PANDAS_AND_NUMPY_VERSION, n) + polars = random.sample(POLARS_VERSION, n) + pyarrow = random.sample(PYARROW_VERSION, n) return [ {"pandas": pd, "numpy": np, "polars": pl, "pyarrow": pa} - for pd, np, pl, pa in picks + for (pd, np), pl, pa in zip(pandas_numpy, polars, pyarrow, strict=True) ] def to_requirements(combo: dict[str, str]) -> str: + """Render a single combo as the contents of a requirements.txt file.""" return ( f"numpy=={combo['numpy']}\n" f"pandas=={combo['pandas']}\n" @@ -85,6 +83,7 @@ def to_requirements(combo: dict[str, str]) -> str: def main() -> None: + """Generate version combos and write them to the requested output path.""" parser = argparse.ArgumentParser() parser.add_argument("-n", "--num", type=int, default=1) parser.add_argument( @@ -99,16 +98,20 @@ def main() -> None: ) args = parser.parse_args() - combos = sample_distinct(args.num) + num: int = args.num + output: Path = args.output - if args.output.suffix == ".json": - args.output.write_text(json.dumps(combos), "utf-8") + combos = sample_distinct(n=num) + + if output.suffix == ".json": + output.write_text(json.dumps(combos), "utf-8") return - if args.num != 1: - msg = f"Non-JSON output ({args.output.suffix or 'no extension'}) requires --num=1" + if num != 1: + msg = f"Non-JSON output ({output.suffix or 'no extension'}) requires --num=1" raise ValueError(msg) - args.output.write_text(to_requirements(combos[0]), "utf-8") + + output.write_text(to_requirements(combos[0]), "utf-8") if __name__ == "__main__":