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..9062e21eb --- /dev/null +++ b/.github/actions/setup-faust/action.yml @@ -0,0 +1,145 @@ +--- +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 }} + id: python + 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 + # 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 + # 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 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 + 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..db2cffd8c 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,32 @@ 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. + # 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' + 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 +113,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..0a0dad1aa 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,76 @@ 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 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. + # + # 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 + ${{ 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 # leg cannot quietly go back to testing pure Python if the build stops @@ -181,18 +106,24 @@ 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 + # `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: token: ${{ secrets.CODECOV_TOKEN }} + test-freethreading: name: 'Python ${{ matrix.python-version }} (free-threaded)' runs-on: ubuntu-latest @@ -208,38 +139,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 +164,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 +195,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 +264,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 +312,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 +330,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 +343,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 +351,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..48f81b348 100755 --- a/scripts/tests +++ b/scripts/tests @@ -11,7 +11,15 @@ 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, 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 #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()