Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 78 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,16 @@ jobs:
- name: Run lint
run: make lint

# The Windows wheel build compiles this arm but without `-D warnings`,
# so it catches a Windows arm that fails to build and nothing else. This
# step is what holds the `#[cfg(windows)]` and `#[cfg(unix)]` branches to
# the same warnings posture as the host.
- name: Install the Windows target standard library
run: rustup target add x86_64-pc-windows-msvc

- name: Lint the Windows cfg branches
run: make lint-windows

typecheck-test:
name: Typecheck and test (Python ${{ matrix.python-label }})
continue-on-error: ${{ matrix.experimental }}
Expand Down Expand Up @@ -141,6 +151,65 @@ jobs:
- name: Run tests
run: make test

extension-tests:
name: Extension-gated tests (Python/Rust boundary)
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
with:
# This job compiles and runs repository code, so the checkout token
# must not stay in .git/config where that code could reach it.
persist-credentials: false

- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: '3.13'

- name: Install uv
uses: astral-sh/setup-uv@f98e06938123ccabd21905ea5d0069192241f9f1 # v8.3.1
with:
enable-cache: true

# `make develop` compiles the extension, so this job needs the same
# pinned toolchain the other Rust-touching jobs use rather than whatever
# the runner image happens to ship.
- name: Install Rust toolchain
run: |
rustup toolchain install 1.85.0 --profile minimal
rustup default 1.85.0

- name: Prepare Cargo dependencies
run: cargo fetch --locked --manifest-path rust/cuprum-rust/Cargo.toml

# `make build` only syncs dependencies; `make develop` compiles the PyO3
# extension into the same virtual environment. It is the target
# contributors run locally, so this job cannot drift from local setup.
- name: Build the native extension
run: make develop

# These modules are gated on `cuprum._rust_backend_native` being
# importable and skip silently without it, so before this job existed a
# green run was indistinguishable from one that never exercised the
# Python/Rust boundary. `CUPRUM_REQUIRE_RUST_EXTENSION` turns a missing
# extension into a failure, so the gate cannot close again unnoticed.
#
# The behavioural modules are covered too: without an extension the
# `rust_streams` fixture skips their consumer-facing scenarios, the
# dispatcher scenarios skip on `requires_rust_backend`, and
# test_rust_extension_behaviour returns before comparing against the
# installed native module — so they were never boundary coverage in the
# ordinary test jobs either.
#
# The module list itself lives in the Makefile's EXTENSION_TEST_TARGETS,
# so this job and the documented local command cannot drift apart. It is
# deliberately not the whole suite: with the extension present,
# `test_pipeline.py` trips the FD close race tracked by issue #124
# (roadmap 8.1.1) and aborts the interpreter.
- name: Run extension-gated tests
run: make test-extension

coverage:
# Pull requests only. Pushes to main are handled by coverage-main.yml,
# which also uploads the report to CodeScene; guarding here stops
Expand Down Expand Up @@ -287,12 +356,15 @@ jobs:
local prefix="$2"
pushd "${workspace}" >/dev/null

make build
UV_CACHE_DIR=.uv-cache UV_TOOL_DIR=.uv-tools uv run python \
-m ensurepip --upgrade
UV_CACHE_DIR=.uv-cache UV_TOOL_DIR=.uv-tools uv run maturin develop \
--release \
--manifest-path rust/cuprum-rust/Cargo.toml
# `make develop` is the one definition of the extension build:
# `make build`, then `ensurepip --upgrade`, then `maturin
# develop`. The ratchet must measure an optimized build, and that
# is the only thing it needs differently, so it passes the flag
# instead of restating the sequence. This runs after `pushd
# "${workspace}"`, so the target's relative UV_CACHE_DIR and
# UV_TOOL_DIR resolve against the same directory the inline
# commands used.
make develop MATURIN_DEVELOP_FLAGS=--release

UV_CACHE_DIR=.uv-cache UV_TOOL_DIR=.uv-tools uv run python \
benchmarks/pipeline_throughput.py \
Expand Down
52 changes: 51 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ TEST_FLAGS ?= $(CARGO_FLAGS) --jobs 1
TEST_RUSTFLAGS ?= $(RUST_FLAGS) -C codegen-units=1
WHITAKER_CARGO_FLAGS ?= $(CARGO_FLAGS) --jobs 1
WHITAKER_RUSTFLAGS ?= $(RUST_FLAGS) -C codegen-units=1
# Extra flags for the `maturin develop` invocation in the `develop` target.
# Empty by default: a debug build is what contributors and the extension-tests
# job want. The benchmark ratchet needs an optimized build, and an optimized
# build is the *only* thing it needs differently, so it passes `--release`
# here rather than restating the three-step build sequence inline.
MATURIN_DEVELOP_FLAGS ?=
# The Windows arm of the extension's `cfg` branches. `make lint` only ever sees
# the host's arm, and the Windows wheel build compiles without `-D warnings`,
# so warn-level regressions behind `#[cfg(windows)]` — dead code left by a
# `#[cfg(unix)]` gate, most of all — would reach main unremarked.
WINDOWS_TARGET ?= x86_64-pc-windows-msvc
# PyO3 cannot probe an interpreter for the target platform, so the ABI version
# must be stated. Keep it in step with the `python-version` the Windows job in
# .github/workflows/build-wheels.yml builds against.
WINDOWS_PYTHON_VERSION ?= 3.13
PYTEST_CARGO_BUILD_JOBS ?= 1
PYTEST_RUSTFLAGS ?= -C codegen-units=1
TEST_CARGO_BUILD_JOBS ?= 1
Expand All @@ -26,6 +41,20 @@ PYTEST_TARGETS ?= cuprum/unittests/test_*.py \
tests/behaviour/test_[a-h]*.py \
tests/behaviour/test_[i-r]*.py \
tests/behaviour/test_[s-z]*.py
# The modules gated on the compiled extension. Deliberately not the whole
# suite: with the extension installed, test_pipeline.py trips the descriptor
# close race in issue #124 and aborts the interpreter. One definition here,
# consumed by both the extension-tests CI job and the documented local command.
EXTENSION_TEST_TARGETS ?= cuprum/unittests/test_rust_streams.py \
cuprum/unittests/test_rust_streams_boundary_property.py \
cuprum/unittests/test_rust_extension.py \
cuprum/unittests/test_rust_splice.py \
cuprum/unittests/test_rust_errno.py \
cuprum/unittests/test_backend.py \
cuprum/unittests/test_extension_requirement_guard.py \
tests/behaviour/test_rust_streams_behaviour.py \
tests/behaviour/test_rust_extension_behaviour.py \
tests/behaviour/test_stream_backend_pipeline.py
shell_quote = '$(subst ','"'"',$(1))'
TYPOS_VERSION ?= 1.48.0
TYPOS := uv tool run typos@$(TYPOS_VERSION)
Expand All @@ -47,8 +76,9 @@ PYLINT_VERSION ?= 4.0.5
PYLINT = $(UV_RUN_ENV) uv tool run --python $(PYLINT_PYTHON) \
--from '$(PYLINT_PYPY_SHIM)' --with 'pylint==$(PYLINT_VERSION)' pylint-pypy

.PHONY: help all clean build build-release lint fmt check-fmt \
.PHONY: help all clean build build-release lint lint-windows fmt check-fmt \
markdownlint spelling spelling-helper-test nixie test typecheck \
test-extension develop \
benchmark-micro benchmark-e2e \
$(TOOLS) $(VENV_TOOLS)

Expand All @@ -62,6 +92,12 @@ all: build check-fmt lint typecheck test
build: uv .venv ## Build virtual-env and install deps
$(UV_RUN_ENV) uv sync --group dev

# Why this exists and why `ensurepip` comes first: see "Building the
# extension for tests" in docs/developers-guide.md.
develop: build ## Build the native extension into the dev virtual-env
$(UV_RUN_ENV) uv run python -m ensurepip --upgrade
$(UV_RUN_ENV) uv run maturin develop $(MATURIN_DEVELOP_FLAGS) --manifest-path $(RUST_DIR)/cuprum-rust/Cargo.toml

build-release: ## Build artefacts (sdist & wheel)
python -m build --sdist --wheel

Expand Down Expand Up @@ -117,6 +153,15 @@ lint: ruff uv ## Run linters (Ruff, pylint, Clippy, Whitaker)
cd $(RUST_DIR) && $(LOCAL_TOOL_ENV) RUSTFLAGS="$(WHITAKER_RUSTFLAGS)" $(WHITAKER) --all -- $(WHITAKER_CARGO_FLAGS)
+$(MAKE) spelling

lint-windows: ## Lint the Rust extension's Windows cfg branches (cross-target)
@if ! rustup target list --installed | grep -qx '$(WINDOWS_TARGET)'; then \
echo "The $(WINDOWS_TARGET) standard library is required." >&2; \
echo "Install it with: rustup target add $(WINDOWS_TARGET)" >&2; \
exit 1; \
fi
cd $(RUST_DIR) && PYO3_CROSS_PYTHON_VERSION=$(WINDOWS_PYTHON_VERSION) \
$(CARGO) clippy --target $(WINDOWS_TARGET) $(CLIPPY_FLAGS)

typecheck: build ## Run typechecking
$(UV_RUN_ENV) uv sync --group dev
$(UV_RUN_ENV) uv run ty --version
Expand Down Expand Up @@ -154,6 +199,11 @@ test: build uv $(VENV_TOOLS) ## Run tests
cd $(RUST_DIR) && CARGO_BUILD_JOBS="$(TEST_CARGO_BUILD_JOBS)" RUSTFLAGS="$(TEST_RUSTFLAGS)" $(CARGO) test $(TEST_FLAGS) $(BUILD_JOBS); \
fi

# Run `make develop` first. Without the extension the guard fails the run with
# a message naming that command, which is the intended diagnostic.
test-extension: build uv $(VENV_TOOLS) ## Run the extension-gated tests, requiring the extension
CUPRUM_REQUIRE_RUST_EXTENSION=1 $(PYTEST) -v $(EXTENSION_TEST_TARGETS)

benchmark-micro: build uv ## Run pytest-benchmark microbenchmarks
mkdir -p dist/benchmarks
$(UV_RUN_ENV) CUPRUM_RUN_BENCHMARKS=1 uv run pytest -q \
Expand Down
42 changes: 42 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,59 @@ def test_pumps_bytes(rust_streams):

from __future__ import annotations

import os
import typing as typ

import pytest

from cuprum import _rust_backend
from cuprum._backend import _check_rust_available, get_stream_backend
from tests.helpers.extension_requirement import (
REQUIRE_EXTENSION_ENV,
missing_extension_message,
)

if typ.TYPE_CHECKING:
from types import ModuleType


def pytest_configure(config: pytest.Config) -> None:
"""Fail the run when the extension is required but absent.

Parameters
----------
config : pytest.Config
The session configuration. Unused; the decision depends only on the
environment and on whether the extension is importable.

Raises
------
pytest.UsageError
If ``CUPRUM_REQUIRE_RUST_EXTENSION`` is set to a non-empty value and
the native extension is unavailable.

Notes
-----
The extension-gated modules skip when the native extension is unavailable,
which is right locally — most contributors do not build it for every
change. In CI it is the wrong default: a job that never builds the
extension reports a green run indistinguishable from one that exercised
the whole Python/Rust boundary.

Setting the variable makes that silence fatal. One session-level check
covers every gated module regardless of how each one gates — fixture,
module-level guard, or availability probe — so a new module cannot opt out
of the requirement by skipping differently.
"""
Comment thread
leynos marked this conversation as resolved.
del config
message = missing_extension_message(
required=bool(os.environ.get(REQUIRE_EXTENSION_ENV)),
available=_rust_backend.is_available(),
)
if message is not None:
raise pytest.UsageError(message)


@pytest.fixture(name="rust_streams")
def fixture_rust_streams() -> ModuleType:
"""Provide the Rust streams module when available.
Expand Down
3 changes: 3 additions & 0 deletions cuprum/unittests/__snapshots__/test_maturin_build.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@
'cuprum/unittests/test_cqrs_hook_behaviour.py',
'cuprum/unittests/test_env_context.py',
'cuprum/unittests/test_env_context_properties.py',
'cuprum/unittests/test_extension_build_contract.py',
'cuprum/unittests/test_extension_ci_contract.py',
'cuprum/unittests/test_extension_requirement_guard.py',
'cuprum/unittests/test_fetch_main_benchmark_baseline.py',
'cuprum/unittests/test_fixture_generation.py',
'cuprum/unittests/test_folded_ranking_property.py',
Expand Down
Loading
Loading