From 75aab992ff3ece8f40b2ed76b991f153beeb5703 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 00:24:19 +0000 Subject: [PATCH 1/4] Cut CI to ~half the job-minutes at equal coverage The workflow had grown to 26 jobs and ~49 job-minutes per pull request, most of it spent re-doing work rather than testing anything new. Same interpreters, same drivers, same suites; less of the overhead around them. Run the suite in parallel. `scripts/tests` now passes `-n auto`, sharding across one worker per core. Measured on a 4-core runner with coverage on, that is 127s -> 49s, and pytest-cov merges the per-worker data files, so the report is byte-identical: 11790 statements, 473 missed, 2562 branches, 99 partial, 95% total, before and after. One test blocked this -- test_wrappers built its parametrize IDs from `datetime.utcnow()` at import time, so every xdist worker collected a different ID and the run aborted. It is now a fixed instant, which nothing in the test depended on. Install with uv. A composite action provisions the interpreter, uv and the dependencies for every job. Installing requirements/test.txt goes from 40.6s to 4.8s, and the eight hand-copied setup blocks -- which is how the cache key and the requirements list had already drifted apart between jobs -- collapse to one place. Fold the confluent legs into the matrix. Six dedicated jobs each paid a full checkout and install to run one file for six seconds. Installing the ckafka extra on every leg costs ~2s and runs the same tests, which stop being `importorskip`-ed: 2494 -> 2555 tests collected. Fold the redis job into the Kafka integration job as a service container, and run all of tests/integration there; each suite still skips itself when its service is unreachable, so neither can pass by not running. Move PyPy to master pushes. It was the single most expensive job (~7.5 minutes, ~15% of a pull request) and it is advisory -- `continue-on-error`, and deliberately not in `check`'s `needs`. master only advances through this workflow, so coverage per merged commit is unchanged. Stop rebuilding docs, CodeQL databases and benchmarks for branches that cannot affect them, and group dependabot into one weekly pull request per ecosystem instead of a daily stream of one-line bumps, each of which was paying for the entire matrix. Security advisories are unaffected. Net: 26 jobs / ~49 job-min -> 19 jobs / ~23 job-min per pull request, and the main workflow drops from 359 to 249 lines of configuration. `check` keeps its name and its `needs`, so branch protection is untouched. actionlint reports the same three pre-existing findings as before. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VnDyaVG75qRRvT3gKGcgbT --- .coveragerc | 7 +- .github/actions/setup-faust/action.yml | 126 ++++++ .github/dependabot.yml | 48 ++- .github/workflows/codeql-analysis.yml | 62 ++- .github/workflows/gh-pages.yml | 62 ++- .github/workflows/performance-benchmark.yml | 11 + .github/workflows/python-package.yml | 400 +++++++------------- requirements/test.txt | 4 + scripts/tests | 8 +- tests/unit/tables/test_wrappers.py | 10 +- 10 files changed, 403 insertions(+), 335 deletions(-) create mode 100644 .github/actions/setup-faust/action.yml diff --git a/.coveragerc b/.coveragerc index 20969fd97..da6949850 100644 --- a/.coveragerc +++ b/.coveragerc @@ -23,8 +23,11 @@ omit = */faust/assignor/* */faust/transport/drivers/memory.py - # optional driver: only importable/tested with the ckafka extra installed - # (faust[ckafka]); not part of the CI test environment. + # optional driver (faust[ckafka]). The CI matrix does now install the + # extra and run tests/unit/transport/drivers/test_confluent.py, but the + # driver stays out of the reported total: a contributor without + # confluent-kafka installed skips those tests, and counting the module + # would drop their local coverage by a couple of points for no reason. */faust/transport/drivers/confluent.py # tested by integration diff --git a/.github/actions/setup-faust/action.yml b/.github/actions/setup-faust/action.yml new file mode 100644 index 000000000..9c1f10545 --- /dev/null +++ b/.github/actions/setup-faust/action.yml @@ -0,0 +1,126 @@ +--- +name: Set up a faust environment +description: > + Provision an interpreter and install faust's dependencies with uv. + + Every job in the CI/CD workflow needs the same three things -- an + interpreter, the dependency set, and (usually) faust itself -- and used to + spell all three out inline. That was eight near-identical copies of the + same twenty lines, which is how the pip cache key and the requirements list + drifted apart between jobs in the first place. Keeping it here means there + is exactly one place to fix when any of it changes. + + The caller must run `actions/checkout` first: GitHub resolves a local + `uses: ./.github/actions/...` from the workspace, so this file does not + exist yet until the repository is on disk. + +inputs: + python-version: + description: > + Interpreter to provision, spelled as `actions/setup-python` spells it + (`3.12`, `3.14t`, `pypy3.11`, ...). + required: true + requirements: + description: > + Requirement files to install, one per line. Passed to uv as `-r` in a + single resolution, so conflicting pins across files fail loudly here + rather than silently depending on install order. + required: false + default: requirements/test.txt + install: + description: > + How to install faust itself: `wheel` (a normal build+install), + `editable` (registers the distribution metadata against the source + tree, which the free-threading job needs), or `none`. + required: false + default: wheel + build-ext: + description: > + Build the Cython extensions in place after installing. See the step + itself for why the in-place copy is the one that matters. + required: false + default: 'false' + +runs: + using: composite + steps: + - name: Set up Python ${{ inputs.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + # Required by the 3.15 rows: a bare `3.15` matches stable releases + # only and fails with "Version 3.15 was not found in the local cache". + # Safe for every other row -- it widens `3.X` to `~3.X.0-0`, and a + # pre-release only wins when no stable release satisfies the spec, so + # 3.10-3.14 still resolve to their newest stable patch. + allow-prereleases: true + + - name: Set up uv + uses: astral-sh/setup-uv@v10 + with: + enable-cache: true + # uv's default glob (`**/*requirements*.txt`) matches + # requirements/requirements.txt and nothing else here, because `*` + # does not cross a `/` -- test.txt, ci.txt and the extras/ files would + # all miss. Hash the same list every job installs from, so they share + # one cache entry per interpreter instead of each warming a partial + # one and none of them ever being invalidated by a moved pin. + cache-dependency-glob: | + requirements/*.txt + requirements/extras/*.txt + + - name: Install dependencies + shell: bash + env: + # Install into the interpreter `setup-python` just put on PATH, + # rather than into a uv-managed virtualenv the later steps would have + # to know to activate. + UV_SYSTEM_PYTHON: '1' + REQUIREMENTS: ${{ inputs.requirements }} + INSTALL: ${{ inputs.install }} + run: | + set -euo pipefail + args=() + while IFS= read -r req; do + [ -n "$req" ] || continue + args+=(-r "$req") + done <<< "$REQUIREMENTS" + uv pip install "${args[@]}" + case "$INSTALL" in + wheel) + uv pip install . + ;; + editable) + # Editable, unlike `wheel`. pytest runs from the repository root, + # so `import faust` resolves to the source tree either way -- but + # the suite also needs the distribution *metadata* to exist, + # because `faust/__init__.py` calls `version("faust-streaming")` + # at import time. An editable install registers that metadata + # against the tree the tests actually import, instead of a second + # copy in site-packages that nothing loads. + USE_CYTHON=1 uv pip install -e . --no-build-isolation + ;; + none) + ;; + *) + echo "::error::unknown install mode '$INSTALL'" + exit 1 + ;; + esac + + - name: Build the Cython extensions in place + # The install above compiles the extensions into site-packages, where + # the tests never see them: pytest runs from the repository root, so + # `import faust` resolves to the source tree, and every accelerated + # import sits behind `try: ... except ImportError`. The fallback + # engages silently, so without this the Cython legs would differ from + # the pure-Python ones only in whether a build step succeeded -- the + # compiled code itself would never be executed by a single test. + # + # Building in place puts the .so files next to the .pyx files, which is + # what the source-tree import actually picks up. It is nearly free: + # the objects were already compiled by the install above, so this only + # copies them out of build/lib.*/ into the tree. + if: inputs.build-ext == 'true' + shell: bash + run: USE_CYTHON=1 python setup.py build_ext --inplace diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5656e69c6..558733dba 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,13 +1,53 @@ version: 2 updates: - # Maintain dependencies for GitHub Actions + # Both ecosystems are grouped and weekly rather than ungrouped and daily. + # + # Every dependabot pull request costs a full CI run, and daily ungrouped + # updates meant a steady stream of one-line bumps each paying for the entire + # matrix -- repeatedly the largest single consumer of CI minutes in the + # repository, for changes that are almost always reviewed and merged as a + # batch anyway. Grouping collapses a week's bumps into one pull request per + # ecosystem, which is one CI run instead of a dozen, and still surfaces + # exactly the same version changes. + # + # Security advisories are not affected by either setting: dependabot opens + # those immediately and ungrouped regardless of the schedule here. - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" + interval: "weekly" + groups: + github-actions: + patterns: + - "*" - # Maintain dependencies for pip - package-ecosystem: "pip" directory: "/requirements" schedule: - interval: "daily" + interval: "weekly" + groups: + # Split so a failing lint-tool bump (black and isort are pinned exactly, + # and a new release reformats the tree) cannot hold back the runtime and + # test dependency updates in the same pull request. + lint-tools: + patterns: + - "black" + - "isort" + - "flake8*" + - "mypy" + - "autoflake" + - "pydocstyle" + - "bandit" + - "pre-commit" + dependencies: + patterns: + - "*" + exclude-patterns: + - "black" + - "isort" + - "flake8*" + - "mypy" + - "autoflake" + - "pydocstyle" + - "bandit" + - "pre-commit" diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 7b48fc25b..99c8fc7a5 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,22 +1,22 @@ --- -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. +# CodeQL security analysis. # +# `languages: python` is the whole of the build configuration: faust's C +# extensions are generated Cython, which CodeQL does not analyse, so there is +# nothing for the autobuild step to do and it is not run. name: CodeQL on: push: branches: [master] + # Only re-analyse a pull request when it actually changes Python. A CodeQL + # run on a docs- or workflow-only branch re-derives the identical database + # for two minutes and reports the identical alerts; the scheduled and + # master-push runs below keep the security dashboard current regardless. pull_request: - # The branches below must be a subset of the branches above branches: [master] + paths: + - '**/*.py' + - .github/workflows/codeql-analysis.yml schedule: - cron: 19 10 * * 6 # Supersede a pull request's analysis when it gets a new push; scheduled and @@ -25,52 +25,34 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + contents: read jobs: analyze: - name: Analyze + # Spelled out rather than derived from a one-entry `language` matrix, which + # is all that matrix ever did. The rendered check name is unchanged, so + # any branch protection referring to it keeps matching. + name: Analyze (python) runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write - strategy: - fail-fast: false - matrix: - language: [python] - # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] - # Learn more: - # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed steps: - name: Checkout repository uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: - languages: ${{ matrix.language }} + languages: python # Cache the dependencies the Python extractor installs to resolve # imports, instead of leaving it to a server-side feature flag. dependency-caching: true - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # queries: ./path/to/local/query, your-org/your-repo/queries@main - - # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). - # If this step fails, then you should remove it and run the build manually (see below) + # Kept for Python: autobuild is what installs the project's dependencies + # so the extractor can resolve imports, and `dependency-caching` above + # caches exactly that work. Dropping it would leave unresolved imports + # and quietly weaken the analysis. - name: Autobuild uses: github/codeql-action/autobuild@v3 - - # â„šī¸ Command-line programs to run using the OS shell. - # 📚 https://git.io/JvXDl - - # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines - # and modify them (or add more) to build your code if your project - # uses a compiled language - - #- run: | - # make bootstrap - # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index b815346c9..bf8066414 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -1,10 +1,37 @@ --- name: Pages on: + # Path-filtered on purpose. This build only reads the documentation sources + # and the `faust` package autodoc imports, so a pull request that touches + # only tests, CI config or packaging metadata cannot change its output -- + # and the daily dependabot action bumps were paying three minutes each to + # rebuild an identical site. `release` stays unfiltered: that run publishes. + # (The two lists are spelled out twice because GitHub Actions does not + # support YAML anchors -- keep them in step.) push: branches: [master] + paths: + - docs/** + - faust/** + - requirements/docs.txt + - requirements/requirements.txt + - README.md + - CHANGELOG.md + - Makefile + - .github/workflows/gh-pages.yml + - .github/actions/setup-faust/action.yml pull_request: branches: [master] + paths: + - docs/** + - faust/** + - requirements/docs.txt + - requirements/requirements.txt + - README.md + - CHANGELOG.md + - Makefile + - .github/workflows/gh-pages.yml + - .github/actions/setup-faust/action.yml release: types: [created] branches: [master] @@ -20,26 +47,31 @@ env: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + contents: read jobs: build: name: Build docs runs-on: ubuntu-latest steps: - # Checkout first: the pip cache key below hashes the requirements files, - # which have to be on disk before `setup-python` runs. + # Checkout first: the composite action below lives in the repository, + # and its cache key hashes files that have to be on disk already. - uses: actions/checkout@v4 with: fetch-depth: 0 # otherwise, you will failed to push refs to dest repo - - uses: actions/setup-python@v5 + - uses: ./.github/actions/setup-faust with: - # Pin the interpreter -- the step used to take whatever the runner - # image shipped -- and cache the wheels. Sphinx and its extensions - # were previously downloaded from PyPI on every single run. python-version: ${{ env.PYTHON_LATEST }} - cache: pip - cache-dependency-path: | - requirements/*.txt - requirements/extras/*.txt + requirements: requirements/docs.txt + # faust itself is installed by the next step, which needs NO_CYTHON. + install: none + - name: Install faust for autodoc + # The docs import faust for autodoc but never touch the compiled + # accelerators, so skip building the C extensions here. + env: + NO_CYTHON: '1' + UV_SYSTEM_PYTHON: '1' + run: uv pip install . # Sphinx builds incrementally: given its doctree cache and the HTML tree # it wrote last time, it only re-reads and re-writes the documents whose # sources changed -- including the `faust` modules autodoc imports, which @@ -80,15 +112,7 @@ jobs: rm -rf docs/_build/html mv Documentation docs/_build/html fi - - name: Install runtime dependencies - # The docs import faust for autodoc but never touch the compiled - # accelerators, so skip building the C extensions here. - env: - NO_CYTHON: '1' - run: | - pip install . - pip install -r requirements/docs.txt - - name: Install doc build deps and build with Sphinx + - name: Build with Sphinx run: make docs - name: Save the Sphinx build cache if: >- diff --git a/.github/workflows/performance-benchmark.yml b/.github/workflows/performance-benchmark.yml index c00519a3e..304b26e4d 100644 --- a/.github/workflows/performance-benchmark.yml +++ b/.github/workflows/performance-benchmark.yml @@ -1,8 +1,19 @@ name: Performance Benchmarks on: + # Path-filtered for pull requests: this job builds and times the library + # itself, so a branch that changes only docs, CI config or packaging cannot + # move the numbers. `push`, `schedule` and `workflow_dispatch` stay + # unfiltered -- the master runs are what feed the benchmark history series, + # and a gap in that series is worse than a redundant data point. pull_request: branches: [master] + paths: + - faust/** + - tests/bench/** + - extra/tools/ci_benchmark.py + - requirements/requirements.txt + - .github/workflows/performance-benchmark.yml push: branches: [master] workflow_dispatch: diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 65f25175b..98bde5cd3 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -18,8 +18,8 @@ env: PIP_NO_PYTHON_VERSION_WARNING: '1' PYTHON_LATEST: '3.12' # One run per pull request at a time: a new push supersedes the run of the -# commit it replaces, so the ~45 job-minutes of a full matrix are not spent -# validating a commit nobody will ever merge. +# commit it replaces, so a full matrix is not spent validating a commit nobody +# will ever merge. # # Cancellation is deliberately limited to `pull_request`. Every other event # gets its own group and always runs to completion: a cancelled `merge_group` @@ -29,151 +29,68 @@ env: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + contents: read jobs: lint: name: Check linting runs-on: ubuntu-latest steps: - - name: Checkout project - uses: actions/checkout@v4 + - uses: actions/checkout@v4 with: fetch-depth: 0 - - name: Set up Python - uses: actions/setup-python@v5 + - uses: ./.github/actions/setup-faust with: python-version: ${{ env.PYTHON_LATEST }} - cache: pip - # Hash every requirements file the workflow installs from, not just - # the default `**/requirements.txt`. The pins this job actually - # installs live in test.txt, which pulls requirements.txt and several - # extras/ files in via `-r`; with the default pattern the key never - # moved when those changed, so the entry could neither be refreshed - # (a hit never re-saves) nor serve the packages that were added. - # Every job here uses the same list so they all share one entry per - # interpreter instead of each warming a partial one. - cache-dependency-path: | - requirements/*.txt - requirements/extras/*.txt - - run: | - python -m pip install build - python -m pip install -r requirements/test.txt - name: Install core libraries for build and install + # isort/black/flake8 read the source tree and `mypy -p faust` + # type-checks it in place, so nothing here needs faust installed. + install: none - name: Run linting checks run: scripts/check + test-pytest: - name: 'Python ${{ matrix.python-version }}/Cython: ${{ matrix.use-cython }}/Driver: ${{ matrix.kafka-driver }}' + name: 'Python ${{ matrix.python-version }}/Cython: ${{ matrix.use-cython }}' runs-on: ubuntu-latest - timeout-minutes: 10 # Maybe we should remove this someday but the PyPy tests are acting strange + timeout-minutes: 10 strategy: - # Complete all jobs even if one fails, allows us to see - # for example if a test fails only when Cython is enabled + # Complete all jobs even if one fails, so we can see for example if a + # test fails only when Cython is enabled. fail-fast: false matrix: - python-version: ['3.10', '3.11', '3.12', '3.13', '3.14'] + python-version: ['3.10', '3.11', '3.12', '3.13', '3.14', '3.15'] use-cython: ['true', 'false'] - experimental: [false] - # aiokafka is the default driver and a core dependency, so it runs the - # full grid. The confluent driver lives behind the optional - # `ckafka` extra; give it a dedicated leg (below) so its tests -- - # tests/unit/transport/drivers/test_confluent.py, otherwise skipped for - # a missing confluent_kafka -- actually run. - kafka-driver: ['aiokafka'] - include: - # Python 3.15. `allow-prereleases` below resolves this to whatever - # pre-release the runner image publishes -- 3.15.0rc1 at the time of - # writing, and 3.15.0 final once it ships, with no change needed - # here. Same shape as a stable row: Cython on and off, plus a - # confluent leg. - # - # Advisory (`experimental: true` -> `continue-on-error`) for two - # reasons. The pre-release one is the same as everywhere else: rc2 - # and final are still to come. The specific one is that faust does - # not import at all on 3.15 until mode-streaming ships the fix for - # `typing._eval_type`, whose `type_params` argument became a required - # positional in 3.15 -- every faust Record model resolves its - # annotations through `mode.utils.objects.annotations`, so collection - # dies before the first test runs. The fix is on mode's master; this - # leg goes green once a mode-streaming release carries it (bump the - # floor in requirements/requirements.txt then, and move these entries - # into the matrix above to make 3.15 required). - - python-version: '3.15' - use-cython: 'true' - experimental: true - kafka-driver: 'aiokafka' - - python-version: '3.15' - use-cython: 'false' - experimental: true - kafka-driver: 'aiokafka' - - python-version: '3.15' - use-cython: 'false' - experimental: true - kafka-driver: 'confluent' - # confluent driver: broker-less unit tests over a pure-Python - # wrapper, so one leg per Python version (Cython off) is enough. - - python-version: '3.10' - use-cython: 'false' - experimental: false - kafka-driver: 'confluent' - - python-version: '3.11' - use-cython: 'false' - experimental: false - kafka-driver: 'confluent' - - python-version: '3.12' - use-cython: 'false' - experimental: false - kafka-driver: 'confluent' - - python-version: '3.13' - use-cython: 'false' - experimental: false - kafka-driver: 'confluent' - - python-version: '3.14' - use-cython: 'false' - experimental: false - kafka-driver: 'confluent' + # Python 3.15 is advisory for two reasons. The pre-release one is that rc2 + # and final are still to come (`allow-prereleases` in the composite action + # resolves `3.15` to whatever the runner image publishes, with no change + # needed here once it ships). The specific one is that faust does not + # import at all on 3.15 until mode-streaming ships the fix for + # `typing._eval_type`, whose `type_params` argument became a required + # positional in 3.15 -- every faust Record model resolves its annotations + # through `mode.utils.objects.annotations`, so collection dies before the + # first test runs. The fix is on mode's master; drop 3.15 from this + # expression to make it required once a mode-streaming release carries it + # (and bump the floor in requirements/requirements.txt then). + continue-on-error: ${{ matrix.python-version == '3.15' }} env: USE_CYTHON: ${{ matrix.use-cython }} - continue-on-error: ${{ matrix.experimental }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: ./.github/actions/setup-faust with: python-version: ${{ matrix.python-version }} - # Required by the 3.15 rows: a bare `3.15` matches stable releases - # only and fails with "Version 3.15 was not found in the local - # cache". Safe for every other row -- it widens `3.X` to - # `~3.X.0-0`, and a pre-release only wins when no stable release - # satisfies the spec, so 3.10-3.14 still resolve to their newest - # stable patch. - allow-prereleases: true - cache: pip - # See the lint job: test.txt alone leaves the key unchanged when the - # transitively-included pins move, and the confluent legs below - # install extras/ckafka.txt on top of it. - cache-dependency-path: | - requirements/*.txt - requirements/extras/*.txt - - name: Install dependencies - run: | - pip install -r requirements/test.txt - pip install . - if [ "${{ matrix.kafka-driver }}" = "confluent" ]; then - pip install -r requirements/extras/ckafka.txt - fi - - name: Build the Cython extensions in place - # `pip install .` above compiles the extensions into site-packages, - # where the tests never see them: pytest runs from the repository - # root, so `import faust` resolves to the source tree, and every - # accelerated import sits behind `try: ... except ImportError`. The - # fallback engaged silently, so these legs differed from the - # `use-cython: false` ones only in whether the build step succeeded -- - # the compiled code itself was never executed by a single test. - # - # Building in place puts the .so files next to the .pyx files, which - # is what the source-tree import actually picks up. - if: matrix.use-cython == 'true' - run: USE_CYTHON=1 python setup.py build_ext --inplace + # ckafka on every leg, so the confluent driver's unit tests -- + # tests/unit/transport/drivers/test_confluent.py, which + # `importorskip`s on confluent_kafka -- actually run. They are + # broker-less tests over a pure-Python wrapper and add ~2s to a leg + # that already installs and runs everything else; they used to get + # six dedicated jobs, each paying a full checkout and install for + # six seconds of tests. + requirements: | + requirements/test.txt + requirements/extras/ckafka.txt + build-ext: ${{ matrix.use-cython }} - name: Run tests # FAUST_REQUIRE_CYTHON turns a silent fallback into a failure, so this # leg cannot quietly go back to testing pure Python if the build stops @@ -181,18 +98,12 @@ jobs: # tests/unit/test_cython_parity.py. env: FAUST_REQUIRE_CYTHON: ${{ matrix.use-cython == 'true' && '1' || '' }} - run: | - if [ "${{ matrix.kafka-driver }}" = "confluent" ]; then - # Dedicated confluent leg: run just the confluent driver's unit - # tests (the aiokafka legs already cover the rest of the suite). - pytest tests/unit/transport/drivers/test_confluent.py - else - scripts/tests - fi + run: scripts/tests - name: Enforce coverage uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} + test-freethreading: name: 'Python ${{ matrix.python-version }} (free-threaded)' runs-on: ubuntu-latest @@ -208,38 +119,21 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: ./.github/actions/setup-faust with: python-version: ${{ matrix.python-version }} - cache: pip - cache-dependency-path: | - requirements/*.txt - requirements/extras/*.txt - - name: Install dependencies - # Not requirements/test.txt: parts of it cannot be built on a - # free-threaded interpreter at all (twine -> cffi, and hypothesis' - # PyO3 extension on 3.13t). freethreading.txt is that list minus the - # ones that fail, and documents each omission. - run: | - pip install -r requirements/freethreading.txt - pip install 'Cython>=3.1' setuptools setuptools_scm - # Editable, unlike the other jobs' `pip install .`. pytest runs from - # the repo root, so `import faust` resolves to the source tree either - # way -- but the suite also needs the distribution *metadata* to - # exist, because `faust/__init__.py` does - # `version("faust-streaming")` at import time. An editable install - # registers that metadata against the tree the tests actually import, - # instead of a second copy in site-packages that nothing loads. - USE_CYTHON=1 pip install -e . --no-build-isolation - - name: Build the Cython extensions in place - # The extensions have to sit next to the .pyx files or they are never - # imported: `faust/streams.py` and friends pull their accelerated - # implementation in behind `try: ... except ImportError`, so a missing - # .so silently falls back to pure Python and the job would test - # something other than what it thinks. This is also what lets - # tests/unit/test_free_threading.py import the extensions rather than - # skipping. - run: USE_CYTHON=1 python setup.py build_ext --inplace + # Not requirements/test.txt: parts of it cannot be built on a + # free-threaded interpreter at all (twine -> cffi, and hypothesis' + # PyO3 extension on 3.13t). freethreading.txt is that list minus + # the ones that fail, and documents each omission. build.txt is the + # toolchain the editable `--no-build-isolation` install needs on the + # ambient interpreter, including the `Cython>=3.1` floor that makes + # `freethreading_compatible` take effect. + requirements: | + requirements/freethreading.txt + requirements/build.txt + install: editable + build-ext: 'true' - name: Verify the extensions did not silently re-enable the GIL # Fails loudly if an extension is missing `freethreading_compatible`, # rather than leaving it to a RuntimeWarning nobody reads. Runs @@ -250,19 +144,30 @@ jobs: # *dependency* re-enables it (aiokafka's _crecords does, today), so # the suite really is exercised without a GIL rather than quietly # falling back to one. + # + # Deliberately serial, unlike scripts/tests: this job exists to + # exercise faust's own threading behaviour under a single no-GIL + # interpreter, and sharding the suite across xdist worker *processes* + # would spread that across several interpreters instead. env: PYTHON_GIL: '0' # As in the main matrix: fail rather than silently fall back to pure # Python if the extensions stop being importable from the tree. FAUST_REQUIRE_CYTHON: '1' run: python -m pytest tests/unit tests/functional -q --no-cov + test-pypy: name: 'Python pypy3.11/Cython: false' runs-on: ubuntu-latest - # PyPy runs the pure-Python paths and is markedly slower than CPython, and - # this leg now runs the formerly-skipped tests too; give it headroom over - # the old 10-minute cap, which already clipped the run at ~96%. timeout-minutes: 15 + # Advisory, and master-only. PyPy exercises the pure-Python paths that + # the CPython legs already cover, its result gates nothing + # (`continue-on-error`, and it is not in `check`'s `needs`), and it was + # comfortably the most expensive job in the workflow -- ~7.5 minutes, + # around 15% of every pull request's CI time, for a signal no one can + # block on. Running it on `push` keeps the coverage per *merged commit* + # identical, since master only ever advances through this workflow. + if: github.event_name == 'push' continue-on-error: true env: USE_CYTHON: 'false' @@ -270,56 +175,56 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: ./.github/actions/setup-faust + id: setup + continue-on-error: true with: python-version: pypy3.11 - cache: pip - cache-dependency-path: | - requirements/*.txt - requirements/extras/*.txt - - name: Install dependencies - id: install - continue-on-error: true - run: | - pip install -r requirements/test.txt - pip install . - name: Run tests - if: steps.install.outcome == 'success' - # `scripts/tests` also runs bandit after pytest, and pytest coverage on - # PyPy is expensive enough to push this advisory leg into the timeout. - # Run pytest directly without coverage, and stop at the first failure so - # a red job reports the real test error instead of a timeout. + if: steps.setup.outcome == 'success' + # `scripts/tests` also runs bandit after pytest, and pytest coverage + # on PyPy is expensive enough to push this advisory leg into the + # timeout. Run pytest directly without coverage, and stop at the + # first failure so a red job reports the real test error instead of a + # timeout. run: > python -m pytest tests/unit tests/functional tests/integration - tests/meticulous tests/regression -x --no-cov + tests/meticulous tests/regression -x --no-cov -n auto + test-integration: name: 'Integration (Kafka ${{ matrix.kafka-version }})' runs-on: ubuntu-latest timeout-minutes: 10 - # Advisory for now: broker tests can be flaky while we stabilise them, so - # a red here must not block the required checks / the merge queue. This - # job is intentionally NOT in the `check` job's `needs`. + # Advisory for now: service-backed tests can be flaky while we stabilise + # them, so a red here must not block the required checks / the merge + # queue. This job is intentionally NOT in the `check` job's `needs`. continue-on-error: true strategy: fail-fast: false matrix: # Test against both a 3.x broker and Kafka 4.x (KRaft-only, released - # 2025). The `KAFKA_CONTROLLER_QUORUM_VOTERS` static-voter config below - # works for both. + # 2025). The `KAFKA_CONTROLLER_QUORUM_VOTERS` static-voter config + # below works for both. kafka-version: ['3.8.1', '4.0.0'] + services: + # Redis rides along rather than getting a job of its own: the cache + # tests take about five seconds, and standing up a whole runner for them + # cost ten times as much as the tests themselves. `tests/integration` + # below runs both suites, and each one skips itself when its service is + # unreachable, so neither can quietly pass by not running. + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 5 steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - cache: pip - # This job also installs extras/ckafka.txt, which the old - # test.txt-only key did not cover. - cache-dependency-path: | - requirements/*.txt - requirements/extras/*.txt # Run Kafka as a plain container (not a GH `services:` container) so its # very chatty broker log stays inside the container -- retrievable on # demand via `docker logs` -- and the job log shows the pytest output. @@ -339,79 +244,47 @@ jobs: -e KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS=0 \ -e KAFKA_AUTO_CREATE_TOPICS_ENABLE=true \ apache/kafka:${{ matrix.kafka-version }} - - name: Install dependencies - run: | - pip install -r requirements/test.txt - pip install -r requirements/extras/ckafka.txt - pip install . + # Starting the broker before installing means the container boots while + # uv resolves, and the wait below is usually already satisfied. + - uses: ./.github/actions/setup-faust + with: + python-version: ${{ env.PYTHON_LATEST }} + requirements: | + requirements/test.txt + requirements/extras/ckafka.txt - name: Wait for Kafka to be ready run: | - python - <<'PY' - import socket, time, sys - deadline = time.monotonic() + 120 - while time.monotonic() < deadline: - try: - with socket.create_connection(("localhost", 9092), 2): - print("Kafka port is open") - break - except OSError: - time.sleep(2) - else: - sys.exit("Kafka did not become reachable in time") - PY - - name: Run live-broker integration tests + # 60 * (up to 2s connect + 2s sleep) -- the same ~120s ceiling the + # previous Python version of this step used. stderr is dropped + # because a refused connection is the expected state while the + # broker boots, and 60 "Connection refused" lines are just noise. + for _ in $(seq 60); do + if timeout 2 bash -c '/dev/null; then + echo "Kafka port is open" + exit 0 + fi + sleep 2 + done + echo "::error::Kafka did not become reachable in time" + exit 1 + - name: Run service-backed integration tests env: FAUST_TEST_BROKER: 'kafka://localhost:9092' - run: pytest tests/integration/broker -v -ra --tb=short --no-cov + FAUST_TEST_REDIS: 'redis://localhost:6379' + run: pytest tests/integration -v -ra --tb=short --no-cov # Only the tail of the broker log on failure, so it augments rather than # buries the pytest output in the job log. - name: Kafka broker logs (on failure) if: failure() run: docker logs --tail 40 kafka - test-redis-integration: - name: 'Redis cache integration' - runs-on: ubuntu-latest - timeout-minutes: 10 - # Advisory for now, like the Kafka integration job: intentionally NOT in - # the `check` job's `needs`, so a red here does not block the merge queue. - continue-on-error: true - services: - redis: - image: redis:7 - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 5s - --health-timeout 3s - --health-retries 5 - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - uses: actions/setup-python@v5 - with: - python-version: '3.12' - cache: pip - cache-dependency-path: | - requirements/*.txt - requirements/extras/*.txt - - name: Install dependencies - run: | - pip install -r requirements/test.txt - pip install . - # Runs the redis cache backend against the real server started above -- - # the unit tests only ever mock the client. - - name: Run redis cache integration tests - env: - FAUST_TEST_REDIS: 'redis://localhost:6379' - run: pytest tests/integration/cache -v -ra --tb=short --no-cov + check: # This job does nothing and is only used for the branch protection name: ✅ Ensure the required checks passing if: always() # test-freethreading gates too: `[tool.cibuildwheel]` publishes cp313t and # cp314t wheels, and a wheel we ship should not be able to go out on a red - # run. (The integration jobs stay out of this list -- they are advisory.) + # run. (test-integration and test-pypy stay out of this list -- they are + # advisory.) needs: [lint, test-pytest, test-freethreading] runs-on: ubuntu-latest steps: @@ -419,6 +292,7 @@ jobs: uses: re-actors/alls-green@release/v1 with: jobs: ${{ toJSON(needs) }} + build_wheels: name: đŸ“Ļ Build wheels on ${{ matrix.os }} runs-on: ${{ matrix.os }} @@ -436,10 +310,6 @@ jobs: with: fetch-depth: 0 - name: Build wheels - # cibuildwheel 2.21.3 predates CPython 3.14, so `build = "cp3*"` - # stopped at cp313 and no 3.14 wheels were published (issue #715). - # 4.x builds 3.14 by default and still supports cp310-cp313. - # # 4.2.0 is the first pin that knows about CPython 3.15, and it builds # cp315 by default -- no `CIBW_ENABLE: cpython-prerelease` needed, # because cibuildwheel gates a Python behind that group only until its @@ -453,6 +323,7 @@ jobs: name: cibw-wheels-${{ matrix.os }} path: ./wheelhouse/*.whl if-no-files-found: error + build_sdist: name: đŸ“Ļ Build the source distribution runs-on: ubuntu-latest @@ -460,32 +331,25 @@ jobs: if: github.event_name == 'release' && github.event.action == 'created' steps: - uses: actions/checkout@v4 - name: Checkout source repository with: fetch-depth: 0 - uses: actions/setup-python@v5 with: - # Pin the interpreter (the step previously took whatever the runner - # image shipped) so the pip cache below has a stable key. python-version: ${{ env.PYTHON_LATEST }} - cache: pip - cache-dependency-path: | - requirements/*.txt - requirements/extras/*.txt - name: Build sdist # Use `python -m build`, not the deprecated `setup.py sdist`, so the # sdist is named with the normalized project name # (`faust_streaming-*.tar.gz`). PyPI enforces PEP 625 and rejects the # legacy hyphenated `faust-streaming-*.tar.gz`. - run: > - pip3 install build pkgconfig cython --upgrade && - python3 -m build --sdist --outdir dist + run: | + python -m pip install build pkgconfig cython --upgrade + python -m build --sdist --outdir dist - uses: actions/upload-artifact@v4 - name: Upload build artifacts with: name: cibw-sdist path: dist/*.tar.gz if-no-files-found: error + publish: name: đŸ“Ļ Publish to PyPI runs-on: ubuntu-latest diff --git a/requirements/test.txt b/requirements/test.txt index ab3faa506..7688f689d 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -20,6 +20,10 @@ pytest-picked pytest-cov pytest-random-order>=0.5.4 pytest-run-parallel>=0.10.0 +# Shards the suite across cores; see the note in scripts/tests. Note this is +# process-level parallelism and unrelated to pytest-run-parallel above, which +# runs a single test on many *threads* to check free-threading safety. +pytest-xdist>=3.0 pytest<8 python-dateutil>=2.8 pytz>=2018.7 diff --git a/scripts/tests b/scripts/tests index c6661a54d..73b05966e 100755 --- a/scripts/tests +++ b/scripts/tests @@ -11,7 +11,13 @@ if [ -z $GITHUB_ACTIONS ]; then scripts/check fi -${PREFIX}pytest tests/unit tests/functional tests/integration tests/meticulous/ tests/regression $@ +# `-n auto` shards the suite across one worker process per core. On the +# 4-core CI runners this takes the run from ~127s to ~49s with coverage on, +# and pytest-cov merges the per-worker data files, so the resulting report is +# identical -- same statements, same branches, same total. It comes last +# before "$@" so a caller can still force serial (`scripts/tests -n0`) when a +# failure needs a readable traceback or a debugger. +${PREFIX}pytest tests/unit tests/functional tests/integration tests/meticulous/ tests/regression -n auto $@ ${PREFIX}bandit -b extra/bandit/baseline.json -c extra/bandit/config.yaml -r faust #if [ -z $GITHUB_ACTIONS ]; then diff --git a/tests/unit/tables/test_wrappers.py b/tests/unit/tables/test_wrappers.py index 3e3a6f7e4..50a657d6f 100644 --- a/tests/unit/tables/test_wrappers.py +++ b/tests/unit/tables/test_wrappers.py @@ -12,7 +12,15 @@ from faust.tables.wrappers import WindowSet from faust.types import Message -DATETIME = datetime.utcnow() +# A fixed instant, not `datetime.utcnow()`. This value reaches +# `@pytest.mark.parametrize` below, so it is baked into the test IDs at +# collection time -- with "now" every pytest-xdist worker imported the module a +# few microseconds apart and generated a *different* ID, which xdist rejects as +# "Different tests were collected between gw0 and gw1". Nothing here depends on +# the instant being the current one, only on it having sub-second precision so +# the ISO-8601 round-trip below is meaningful. (`utcnow()` is also deprecated +# since 3.12.) +DATETIME = datetime(2020, 3, 15, 12, 34, 56, 789012) DATETIME_TS = DATETIME.timestamp() From 0c6e17b9b051a09cdb941c3e5c74cc851ee924f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 00:34:55 +0000 Subject: [PATCH 2/4] Pin setup-uv to an exact tag so the action resolves astral-sh/setup-uv stopped publishing floating major aliases after v7, so `@v10` is not a ref at all and every job using the composite action died at "Unable to resolve action `astral-sh/setup-uv@v10`" before running a step. Only the exact tags (v8.0.0, v9.0.0, v10.0.0) exist past v7. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VnDyaVG75qRRvT3gKGcgbT --- .github/actions/setup-faust/action.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-faust/action.yml b/.github/actions/setup-faust/action.yml index 9c1f10545..f9d1f4483 100644 --- a/.github/actions/setup-faust/action.yml +++ b/.github/actions/setup-faust/action.yml @@ -56,7 +56,11 @@ runs: allow-prereleases: true - name: Set up uv - uses: astral-sh/setup-uv@v10 + # Pinned to an exact tag, not a `v10` major alias: setup-uv stopped + # publishing floating major tags after v7, so `@v8`/`@v9`/`@v10` do not + # resolve at all ("unable to find version `v10`"). Dependabot's + # github-actions updater bumps this pin like any other. + uses: astral-sh/setup-uv@v10.0.0 with: enable-cache: true # uv's default glob (`**/*requirements*.txt`) matches From f78b7f965dd521cf7d16ec3c32e0ebc4e9c5124f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 00:38:43 +0000 Subject: [PATCH 3/4] Target the provisioned interpreter explicitly, and skip ckafka on 3.15 Two failures from the first green-field run of the new pipeline. uv was picking the wrong interpreter. `UV_SYSTEM_PYTHON=1` alone tells uv to install outside a virtualenv, but leaves it to *find* the interpreter on PATH -- and its discovery skips free-threaded builds unless the request names a `t` ABI. On the 3.13t leg it walked past the interpreter setup-python had just installed and settled on Debian's /usr/bin/python3, which failed as "externally managed" and would have been the wrong interpreter even if it had succeeded. Pass setup-python's `python-path` output as UV_PYTHON so the target is named rather than guessed, on every leg including pypy, and export it to the job env for later steps that shell out to uv (the docs build). confluent-kafka has no cp315 wheel, so putting the ckafka extra on every leg made the 3.15 legs fall back to its sdist, which needs librdkafka headers the runner does not have -- "fatal error: librdkafka/rdkafka.h: No such file or directory" took down the whole install before any test ran. Install the extra everywhere except 3.15. Everything else in that run was green: lint, both integration legs, all ten 3.10-3.14 legs, and the 3.14t free-threaded leg end to end. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VnDyaVG75qRRvT3gKGcgbT --- .github/actions/setup-faust/action.yml | 21 ++++++++++++++++++--- .github/workflows/gh-pages.yml | 3 ++- .github/workflows/python-package.yml | 18 +++++++++++++----- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/.github/actions/setup-faust/action.yml b/.github/actions/setup-faust/action.yml index f9d1f4483..9062e21eb 100644 --- a/.github/actions/setup-faust/action.yml +++ b/.github/actions/setup-faust/action.yml @@ -45,6 +45,7 @@ runs: using: composite steps: - name: Set up Python ${{ inputs.python-version }} + id: python uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} @@ -76,14 +77,28 @@ runs: - name: Install dependencies shell: bash env: - # Install into the interpreter `setup-python` just put on PATH, - # rather than into a uv-managed virtualenv the later steps would have - # to know to activate. + # Install into the interpreter `setup-python` just provisioned, rather + # than into a uv-managed virtualenv the later steps would have to know + # to activate. + # + # UV_PYTHON names that interpreter by absolute path instead of letting + # uv search PATH. The search is not equivalent: uv's discovery skips + # free-threaded interpreters unless the request explicitly asks for a + # `t` ABI, so on the 3.13t/3.14t legs it walked past the interpreter we + # had just installed and settled on Debian's /usr/bin/python3 -- which + # then failed as "externally managed", and would have been the *wrong + # interpreter* even if it had succeeded. `python-path` is exactly what + # setup-python resolved, for every leg including pypy. UV_SYSTEM_PYTHON: '1' + UV_PYTHON: ${{ steps.python.outputs.python-path }} REQUIREMENTS: ${{ inputs.requirements }} INSTALL: ${{ inputs.install }} run: | set -euo pipefail + # Carry the interpreter choice to any later step in the calling job + # that shells out to uv (the docs build does). + echo "UV_SYSTEM_PYTHON=1" >> "$GITHUB_ENV" + echo "UV_PYTHON=$UV_PYTHON" >> "$GITHUB_ENV" args=() while IFS= read -r req; do [ -n "$req" ] || continue diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index bf8066414..db2cffd8c 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -68,9 +68,10 @@ jobs: - name: Install faust for autodoc # The docs import faust for autodoc but never touch the compiled # accelerators, so skip building the C extensions here. + # UV_PYTHON/UV_SYSTEM_PYTHON are exported into the job env by the + # composite action above, so this targets the same interpreter. env: NO_CYTHON: '1' - UV_SYSTEM_PYTHON: '1' run: uv pip install . # Sphinx builds incrementally: given its doctree cache and the HTML tree # it wrote last time, it only re-reads and re-writes the documents whose diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 98bde5cd3..d921e98dd 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -80,16 +80,24 @@ jobs: - uses: ./.github/actions/setup-faust with: python-version: ${{ matrix.python-version }} - # ckafka on every leg, so the confluent driver's unit tests -- - # tests/unit/transport/drivers/test_confluent.py, which + # ckafka on every leg that can install it, so the confluent driver's + # unit tests -- tests/unit/transport/drivers/test_confluent.py, which # `importorskip`s on confluent_kafka -- actually run. They are # broker-less tests over a pure-Python wrapper and add ~2s to a leg # that already installs and runs everything else; they used to get - # six dedicated jobs, each paying a full checkout and install for - # six seconds of tests. + # six dedicated jobs, each paying a full checkout and install for six + # seconds of tests. + # + # Except on 3.15: confluent-kafka publishes no cp315 wheel yet, so + # the install falls back to its sdist, which needs the librdkafka + # headers that are not on the runner -- the build dies on "fatal + # error: librdkafka/rdkafka.h: No such file or directory" and takes + # the whole leg's install down with it. Drop this condition once a + # cp315 wheel ships. (The empty line the false branch produces is + # skipped by the action's reader.) requirements: | requirements/test.txt - requirements/extras/ckafka.txt + ${{ matrix.python-version != '3.15' && 'requirements/extras/ckafka.txt' || '' }} build-ext: ${{ matrix.use-cython }} - name: Run tests # FAUST_REQUIRE_CYTHON turns a silent fallback into a failure, so this From cff28ff988619bff97f09d0d7a21e04a74ba3a77 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 00:49:55 +0000 Subject: [PATCH 4/4] Run the 3.14 and 3.15 legs serially Sharding the suite with `-n auto` is a large win on 3.10-3.13 and a large loss after it. Measured on the matrix: 65-88s on 3.10-3.13 against ~130s serial, but 380s on 3.15 and past 440s on 3.14, where serial is ~150s. The 3.14 legs are not advisory and carry a 10-minute timeout, so that was heading for a red required check rather than merely a slow one. Why the newer interpreters behave this way is not understood; rather than guess at a cause, restrict sharding to the versions where it is measured to help and leave a note to re-measure. `scripts/tests` already puts "$@" after its own `-n auto`, so a trailing `-n0` is enough to put a leg back on one process. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VnDyaVG75qRRvT3gKGcgbT --- .github/workflows/python-package.yml | 14 +++++++++++++- scripts/tests | 14 ++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index d921e98dd..0a0dad1aa 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -106,7 +106,19 @@ jobs: # tests/unit/test_cython_parity.py. env: FAUST_REQUIRE_CYTHON: ${{ matrix.use-cython == 'true' && '1' || '' }} - run: scripts/tests + # `scripts/tests` shards across cores with `-n auto`; `-n0` puts a leg + # back on a single process (the later -n wins). + # + # Measured on this matrix, sharding is a large win up to 3.13 and a + # large loss after it -- the suite runs in 65-88s on 3.10-3.13, against + # ~130s serial, but took 380s on 3.15 and blew past 440s on 3.14, where + # serial is ~150s. The cause on the newer interpreters is not + # understood yet; until it is, only the versions sharding demonstrably + # helps get it. Re-measure when revisiting: if a fix lands, delete + # this argument rather than extending the list to 3.16. + run: >- + scripts/tests + ${{ (matrix.python-version == '3.14' || matrix.python-version == '3.15') && '-n0' || '' }} - name: Enforce coverage uses: codecov/codecov-action@v5 with: diff --git a/scripts/tests b/scripts/tests index 73b05966e..48f81b348 100755 --- a/scripts/tests +++ b/scripts/tests @@ -11,12 +11,14 @@ if [ -z $GITHUB_ACTIONS ]; then scripts/check fi -# `-n auto` shards the suite across one worker process per core. On the -# 4-core CI runners this takes the run from ~127s to ~49s with coverage on, -# and pytest-cov merges the per-worker data files, so the resulting report is -# identical -- same statements, same branches, same total. It comes last -# before "$@" so a caller can still force serial (`scripts/tests -n0`) when a -# failure needs a readable traceback or a debugger. +# `-n auto` shards the suite across one worker process per core, and +# pytest-cov merges the per-worker data files, so the coverage report is +# identical either way -- same statements, same branches, same total. +# +# It comes before "$@" so the last -n wins and a caller can force serial with +# `scripts/tests -n0`: when a failure needs a readable traceback or a +# debugger, and on the interpreters where sharding is currently slower rather +# than faster (see the note beside the CI matrix's "Run tests" step). ${PREFIX}pytest tests/unit tests/functional tests/integration tests/meticulous/ tests/regression -n auto $@ ${PREFIX}bandit -b extra/bandit/baseline.json -c extra/bandit/config.yaml -r faust