diff --git a/.github/workflows/test-native.yml b/.github/workflows/test-native.yml index 9c444a6..9a17fa7 100644 --- a/.github/workflows/test-native.yml +++ b/.github/workflows/test-native.yml @@ -11,6 +11,11 @@ # workflow_call from release-native.yml with `release: true`: all four platforms, Python 3.11 to 3.14 # This workflow never publishes: it has no id-token permission and no environment. # +# Beside the full parity runs, the `pyver` job installs the already built linux-x86_64 wheel (py3-none: no rebuild) into the Pythons the full +# runs of the event leave out (a pull request: 3.11, 3.13, 3.14; main, nightly, dispatch: 3.11, 3.13; a release: none) and into the free-threaded +# 3.14t, and runs a small subset of the tests there (about 20 s of tests per Python). It is informational: release-native.yml does not need it. +# scripts/select_native_matrix.py decides its legs. +# # One wheel per platform, py3-none-: # linux-x86_64 manylinux_2_34 container manylinux_2_34_x86_64 libarcadedb.so + libjvector-x86_64.so # linux-aarch64 manylinux_2_34 aarch64 container manylinux_2_34_aarch64 libarcadedb.so @@ -106,6 +111,7 @@ jobs: final: ${{ steps.ver.outputs.final }} build: ${{ steps.plan.outputs.build }} test: ${{ steps.plan.outputs.test }} + pyver: ${{ steps.plan.outputs.pyver }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -524,6 +530,7 @@ jobs: run: | set -euo pipefail tsv="$T/parity/parity-wheel.tsv" + # the pyver job has a copy of this awk: keep the two in sync awk -F'\t' ' NR == 1 { next } $1 == "TOTAL" { passed = $2; failed = $3; err = $4; notyet = $5; na = $6; skipped = $7; tot = 1; next } @@ -562,3 +569,124 @@ jobs: ${{ runner.temp }}/parity/parity-wheel-xml/ if-no-files-found: warn retention-days: 30 + + # The cheap Python-version coverage of the wheel of linux-x86_64 (py3-none: the same file for every Python, nothing is rebuilt): a subset of + # the test files, not the whole suite. The legs are the supported Pythons the full parity runs of this event leave out, plus the + # free-threaded 3.14t (scripts/select_native_matrix.py). + # INFORMATIONAL for now: release-native.yml (verify and the publish jobs) reads only the native-passed-* markers and the native-wheel-* wheels, + # never an artifact of this job, so a red leg never blocks a release. Promote it once it has been green for a while. + # 3.14t runs without polars, and so without test_dataframe_ingest.py (it imports polars at module level): polars ships abi3 wheels only + # (polars and polars-runtime-32 have no cp314t wheel, and an abi3 wheel cannot be installed on a free-threaded build). The other + # dependencies of the subset (numpy, pandas, pyarrow, jpype1, requests) all have cp314t wheels. polars is an optional extra of the wheel. + pyver: + name: Python ${{ matrix.python }} on the wheel (linux-x86_64) + needs: [plan, build] + # The linux-x86_64 build may have failed: then there is no wheel to download and this shows red, as the test job does. + if: ${{ !cancelled() && needs.plan.outputs.run == 'true' }} + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + shell: bash + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.pyver) }} + env: + KEY: linux-x86_64 + PY: ${{ matrix.python }} + WITH_POLARS: ${{ matrix.polars }} + FREE_THREADED: ${{ matrix.freethreaded }} + # The subset: ctypes layer and database lifecycle (test_core, test_native_udf), queries and results (test_resultset), parameters + # (test_parameter_binding), type conversion (test_type_conversion), transactions (test_transaction_config), async (test_async_executor), + # threads (test_concurrency, test_free_threading), vectors (test_vector), numpy (test_numpy_support), arrow (test_resultset_arrow), pandas + # and arrow readers (test_columnar_readers). test_dataframe_ingest (pandas, polars and arrow ingest) is added where polars installs. + SUBSET: test_core.py,test_resultset.py,test_type_conversion.py,test_parameter_binding.py,test_transaction_config.py,test_async_executor.py,test_vector.py,test_numpy_support.py,test_resultset_arrow.py,test_columnar_readers.py,test_native_udf.py,test_concurrency.py,test_free_threading.py + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Paths + run: echo "T=$RUNNER_TEMP" >> "$GITHUB_ENV" + + - name: Install uv + run: | + installer="$(mktemp)" + curl -LsSf --retry 5 --retry-all-errors --retry-delay 5 -o "$installer" "https://astral.sh/uv/${UV_VERSION}/install.sh" + sh "$installer" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + # The wheel of the build job, not a rebuild: the very file the test job installs. + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: native-wheel-linux-x86_64 + path: ${{ runner.temp }}/wheel + + - name: Run the subset against the installed wheel + run: | + set -euo pipefail + w="$(ls "$T"/wheel/*.whl)" + mkdir -p "$T/parity" + files="$SUBSET" + with_polars=() + if [ "$WITH_POLARS" = true ]; then + files="$files,test_dataframe_ingest.py" + with_polars=(--with polars) + fi + echo "python $PY, files: $files" + cores="$(nproc)" + uv run --no-project --python "$PY" --with "$w" --with pytest --with numpy --with requests --with jpype1 --with pyarrow \ + --with pandas ${with_polars[@]+"${with_polars[@]}"} \ + python native/wheel/parity_wheel.py --repo "$GITHUB_WORKSPACE" --files "$files" \ + --out "$T/parity/parity-wheel.tsv" --cores "0-$((cores - 1))" | tee "$T/parity/parity-wheel.run.log" + + # A free-threaded leg only means something if the GIL stays off once the wheel and the extension modules are loaded. + - name: Report whether the GIL is enabled + run: | + set -euo pipefail + w="$(ls "$T"/wheel/*.whl)" + uv run --no-project --python "$PY" --with "$w" --with numpy --with pandas --with pyarrow --with jpype1 python - <<'PY' | tee -a "$GITHUB_STEP_SUMMARY" + import os + import sys + + import arcadedb # noqa: F401 + import jpype # noqa: F401 + import numpy # noqa: F401 + import pandas # noqa: F401 + import pyarrow # noqa: F401 + + gil = sys._is_gil_enabled() if hasattr(sys, "_is_gil_enabled") else True + print(f"python {sys.version.split()[0]}, GIL enabled after loading the wheel and its dependencies: {gil}") + if os.environ["FREE_THREADED"] == "true" and gil: + print("::warning::the free-threaded leg re-enabled the GIL: it does not test free threading") + PY + + - name: "Gate: no failure, error, crash, timeout or uncounted skip" + run: | + set -euo pipefail + tsv="$T/parity/parity-wheel.tsv" + # the same awk as the gate of the test job: keep the two in sync + awk -F'\t' ' + NR == 1 { next } + $1 == "TOTAL" { passed = $2; failed = $3; err = $4; notyet = $5; na = $6; skipped = $7; tot = 1; next } + $8 != "" { bad = bad " " $1 "(" $8 ")" } + END { + if (!tot) { print "no TOTAL row" > "/dev/stderr"; exit 1 } + printf "parity %s py%s: passed=%s failed=%s error=%s notyet=%s na_jvm=%s skipped=%s\n", ENVIRON["KEY"], ENVIRON["PY"], passed, failed, err, notyet, na, skipped + if (bad != "") { print "files with a status:" bad > "/dev/stderr"; exit 1 } + if (passed + 0 == 0 || failed + 0 != 0 || err + 0 != 0) exit 1 + # this repository accepts no skips; NOTYET and NA-JVM are the two counted kinds of the native build + if (skipped + 0 != 0) { print "skipped tests that are neither NOTYET nor NA-JVM: " skipped > "/dev/stderr"; exit 1 } + }' "$tsv" | tee -a "$GITHUB_STEP_SUMMARY" + + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + if: ${{ !cancelled() }} + with: + name: native-parity-pyver-py${{ matrix.python }} + path: | + ${{ runner.temp }}/parity/parity-wheel.tsv + ${{ runner.temp }}/parity/parity-wheel.run.log + ${{ runner.temp }}/parity/parity-wheel-xml/ + if-no-files-found: warn + retention-days: 30 diff --git a/docs/modules/ROOT/pages/development/ci.adoc b/docs/modules/ROOT/pages/development/ci.adoc index 9a4c289..d1ba2ae 100644 --- a/docs/modules/ROOT/pages/development/ci.adoc +++ b/docs/modules/ROOT/pages/development/ci.adoc @@ -55,6 +55,7 @@ The workflow `test-native.yml` builds the native image of the `arcadedb` package * A pull request runs it only when native paths change: `native/**`, `src/arcadedb/_native/**`, the native tests, or `test-native.yml` itself. It then runs on `linux-x86_64` with Python 3.12 only. * A push to `main`, the nightly run, a manual run (dispatch) run all four platforms: `linux-x86_64`, `linux-aarch64`, `macos-arm64` and `windows-x64` (Python 3.12 and 3.14). A release (`release-native.yml` calls the workflow with `release: true`) runs the same four platforms on all four Python versions, 16 legs. +* The `pyver` job installs the already built `linux-x86_64` wheel (no rebuild) into the Pythons that the full parity runs of the event leave out (3.11, 3.13 and 3.14 on a pull request; 3.11 and 3.13 on a push to `main`, the nightly run and a dispatch; none on a release) and into the free-threaded 3.14t, and runs 13 test files there (14 where polars installs), about 20 seconds of tests per Python. It is informational: `release-native.yml` reads only the `native-passed-*` markers and the `native-wheel-*` wheels, so a red `pyver` leg never blocks a release. The 3.14t leg has no polars (no free-threaded wheel exists), so it skips `test_dataframe_ingest.py`. `scripts/select_native_matrix.py` decides its legs. * A native image build takes about 1 to 2.5 hours for each platform. The organisation shares one runner limit with upstream ArcadeDB, so do not add runs lightly. Change the paths or the matrix of `test-native.yml` only for a reason that you can write down. == Change the selection diff --git a/scripts/select_native_matrix.py b/scripts/select_native_matrix.py index d027718..be4cb22 100644 --- a/scripts/select_native_matrix.py +++ b/scripts/select_native_matrix.py @@ -9,8 +9,10 @@ usage: select_native_matrix.py EVENT EVENT: pull_request | push | schedule | workflow_dispatch | release -Prints `build=` and `test=` lines (GITHUB_OUTPUT format). Publishing is not decided here: release-native.yml -does it. The GraalVM CE archives are pinned by sha256 per platform (GraalVM Community Edition only, never Oracle GraalVM). +Prints `build=`, `test=` and `pyver=` lines (GITHUB_OUTPUT format). Publishing is not decided here: +release-native.yml does it. `pyver` is the cheap Python-version job (see PYVER_NOTE): a subset of the tests against the built +linux-x86_64 wheel on the Pythons the full parity run of the event does not cover, and always on the free-threaded 3.14t. It never +gates a release. The GraalVM CE archives are pinned by sha256 per platform (GraalVM Community Edition only, never Oracle GraalVM). """ from __future__ import annotations @@ -87,11 +89,30 @@ PRERELEASE_REQUIRED = ["linux-x86_64", "linux-aarch64", "macos-arm64"] ALL_PLATFORMS = list(PLATFORMS) +# The `pyver` job of test-native.yml installs the already built linux-x86_64 wheel (py3-none, no rebuild) into every Python below that the +# full parity run of the event does not cover, and runs a subset of the test files there. 3.14t is the free-threaded build of 3.14: +# it is not a classifier of the wheel and has no polars (polars ships abi3 wheels only, which a free-threaded Python cannot install). +FREE_THREADED_PYTHONS = ["3.14t"] +PYVER_NOTE = "informational: release-native.yml does not read it" + PULL_REQUEST_PLATFORMS = ["linux-x86_64"] PULL_REQUEST_PYTHONS = ["3.12"] MAIN_PYTHONS = ["3.12", "3.14"] +def pyver_legs(covered: list[str]) -> list[dict]: + """The legs of the cheap Python-version job: every supported Python that `covered` (the full parity run) leaves out.""" + return [ + { + "python": py, + "freethreaded": py in FREE_THREADED_PYTHONS, + "polars": py not in FREE_THREADED_PYTHONS, + } + for py in ALL_PYTHONS + FREE_THREADED_PYTHONS + if py not in covered + ] + + def select(event: str) -> dict: if event == "pull_request": platforms, pythons = PULL_REQUEST_PLATFORMS, PULL_REQUEST_PYTHONS @@ -112,7 +133,7 @@ def select(event: str) -> dict: for k in platforms for py in pythons ] - return {"build": build, "test": test} + return {"build": build, "test": test, "pyver": pyver_legs(pythons)} def main(argv: list[str] | None = None) -> int: @@ -125,6 +146,7 @@ def main(argv: list[str] | None = None) -> int: chosen = select(a.event) print(f"build={json.dumps(chosen['build'], separators=(',', ':'))}") print(f"test={json.dumps(chosen['test'], separators=(',', ':'))}") + print(f"pyver={json.dumps(chosen['pyver'], separators=(',', ':'))}") return 0 diff --git a/tests/test_select_native_matrix.py b/tests/test_select_native_matrix.py index 609aea7..d015daf 100644 --- a/tests/test_select_native_matrix.py +++ b/tests/test_select_native_matrix.py @@ -67,5 +67,63 @@ def test_the_script_prints_github_output_lines(): text=True, check=True, ).stdout.splitlines() - assert [line.split("=", 1)[0] for line in out] == ["build", "test"] + assert [line.split("=", 1)[0] for line in out] == ["build", "test", "pyver"] assert json.loads(out[0].split("=", 1)[1])[0]["key"] == "linux-x86_64" + assert [leg["python"] for leg in json.loads(out[2].split("=", 1)[1])] == [ + "3.11", + "3.13", + "3.14", + "3.14t", + ] + + +def _pyver(event): + return [ + (leg["python"], leg["freethreaded"], leg["polars"]) + for leg in sel.select(event)["pyver"] + ] + + +def test_the_python_version_job_covers_what_the_full_parity_run_leaves_out(): + # a pull request tests 3.12 only: the other three Pythons and the free-threaded one run the cheap subset + assert _pyver("pull_request") == [ + ("3.11", False, True), + ("3.13", False, True), + ("3.14", False, True), + ("3.14t", True, False), + ] + # main, nightly and dispatch test 3.12 and 3.14 in full + for event in ("push", "schedule", "workflow_dispatch"): + assert _pyver(event) == [ + ("3.11", False, True), + ("3.13", False, True), + ("3.14t", True, False), + ] + # a release tests 3.11 to 3.14 in full: only the free-threaded build is left + assert _pyver("release") == [("3.14t", True, False)] + + +def test_the_python_version_job_never_repeats_a_full_parity_leg_and_has_no_polars_on_a_free_threaded_python(): + for event in ("pull_request", "push", "schedule", "workflow_dispatch", "release"): + chosen = sel.select(event) + full = {t["python"] for t in chosen["test"]} + assert not full & {leg["python"] for leg in chosen["pyver"]}, event + assert any(leg["freethreaded"] for leg in chosen["pyver"]), event + for leg in chosen["pyver"]: + assert leg["polars"] is (not leg["freethreaded"]), leg + + +def test_the_release_does_not_need_the_python_version_job(): + workflows = ROOT / ".github" / "workflows" + test_native = (workflows / "test-native.yml").read_text() + release = (workflows / "release-native.yml").read_text() + assert re.search(r"^ pyver:\n", test_native, re.M) + # no job of either workflow waits for it + for text in (test_native, release): + for needs in re.findall(r"^ needs: (.*)$", text, re.M): + assert "pyver" not in needs, needs + # release-native.yml reads the markers and the wheels only, never the artifacts of the pyver job + patterns = re.findall(r"^\s+pattern: (.*)$", release, re.M) + assert patterns and all( + p in ("native-passed-*", "native-wheel-*") for p in patterns + ), patterns