From fdc4ff034a198f92d14ca087bac4e0cf47613dee Mon Sep 17 00:00:00 2001 From: David Hagen Date: Sat, 25 Apr 2026 07:53:14 -0400 Subject: [PATCH 1/5] Capture Rust code coverage --- .github/workflows/ci.yml | 33 +++++------ .gitignore | 3 +- docs/contributing.md | 28 ++++++++- noxfile.py | 121 +++++++++++++++++++++++++++++---------- pyproject.toml | 1 + rust-toolchain.toml | 2 +- uv.lock | 26 +++++++++ 7 files changed, 163 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a211da..08bd8e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,12 +30,10 @@ jobs: steps: - name: Checkout repo uses: actions/checkout@v4 - - name: Build wheel - uses: PyO3/maturin-action@v1 + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 with: - command: build - args: -o dist/ -i python${{ matrix.python }} - sccache: true + rustflags: "" - name: Install uv uses: astral-sh/setup-uv@v5 with: @@ -45,20 +43,18 @@ jobs: - name: Install dependencies run: uv sync --locked --only-group nox - name: Test - run: | - uv run --no-sync nox -s test-${{ matrix.python }} -- --use-dist + run: uv run --no-sync nox -s test-${{ matrix.python }} - name: Test Polars - run: | - uv run --no-sync nox -s test_polars-${{ matrix.python }} -- --use-dist + run: uv run --no-sync nox -s test_polars-${{ matrix.python }} - name: Test Pandas - run: | - uv run --no-sync nox -s test_pandas-${{ matrix.python }} -- --use-dist + run: uv run --no-sync nox -s test_pandas-${{ matrix.python }} - name: Store coverage uses: actions/upload-artifact@v4 with: name: coverage-${{ matrix.platform }}-${{ matrix.python }} - path: .coverage.* - include-hidden-files: true + path: | + python.*.lcov + rust.*.lcov if-no-files-found: error coverage: @@ -67,6 +63,8 @@ jobs: steps: - name: Checkout repo uses: actions/checkout@v4 + - name: Install lcov + run: sudo apt-get update && sudo apt-get install -y lcov - name: Fetch coverage uses: actions/download-artifact@v4 with: @@ -80,12 +78,13 @@ jobs: cache-suffix: coverage - name: Install dependencies run: uv sync --locked --only-group nox - - name: Combine coverage and generate report + - name: Combine coverage run: uv run --no-sync nox -s coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} + files: combined.lcov fail_ci_if_error: true lint: @@ -93,8 +92,10 @@ jobs: steps: - name: Checkout repo uses: actions/checkout@v4 - - name: Setup sccache - uses: mozilla-actions/sccache-action@65101d47ea8028ed0c98a1cdea8dd9182e9b5133 + - name: Install Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1 + with: + cache-key: lint - name: Install uv uses: astral-sh/setup-uv@v5 with: diff --git a/.gitignore b/.gitignore index d406832..e4f98a4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,8 @@ python/**/*.so # coverage /.coverage* -/coverage.xml +/*.lcov +/htmlcov # mkdocs /site diff --git a/docs/contributing.md b/docs/contributing.md index 8f45480..623719b 100644 --- a/docs/contributing.md +++ b/docs/contributing.md @@ -20,13 +20,30 @@ git clone https://github.com/drhagen/tabeline.git ## Installing from source -Tabeline uses uv as its packaging and dependency manager. In whatever Python environment you prefer, [install uv](https://docs.astral.sh/uv/getting-started/installation/) and then use uv to install Tabeline and its dependencies: +Building Tabeline requires that a Rust compiler be available. +In your environment, install [rustup](https://rustup.rs/) and then use it to install the appropriate rust version and rust-related tools: + +```shell +rustup show +``` + +Tabeline uses uv as its Python packaging and dependency manager. +In Python environment you prefer, [install uv](https://docs.astral.sh/uv/getting-started/installation/) and then use uv to install Tabeline and its dependencies: ```shell -pip install uv uv sync ``` +Combining code coverage and generating a coverage report requires that `lcov` be installed. +It is not available via rustup, cargo, or uv, so you'll have to install it from somewhere else: + +```shell +pixi global install lcov +sudo apt install lcov +brew install lcov +choco install lcov +``` + ## Testing Tabeline uses pytest to run the tests in the `tests/` directory. The test command is encapsulated with Nox: @@ -43,6 +60,13 @@ uv run nox -s test-3.13 test_pandas-3.13 It is good to run the tests locally before making a PR, but it is not necessary to have all Python versions run. It is rare for a failure to appear in a single version, and the CI will catch it anyway. +The tests generate code coverage data. +To merge the coverage data and generate an HTML report at `htmlcov/`, run: + +```shell +uv run nox -s coverage +``` + ## Code quality Tabeline uses Ruff to ensure a minimum standard of code quality. The code quality commands are encapsulated with Nox: diff --git a/noxfile.py b/noxfile.py index 3b90c3d..20dade2 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,4 +1,6 @@ import platform +import subprocess +from pathlib import Path from nox import Session, options, parametrize from nox_uv import session @@ -7,25 +9,73 @@ options.sessions = ["test", "test_polars", "test_pandas", "coverage", "lint"] -def _install_project(s: Session): - # There is no way to cache the build of the project in CI without using the - # official Maturin action. So CI separately builds the dist/ wheel and then - # Nox installs the project from that wheel. - # Furthermore, uv does not build the project when using editable installs, - # so we have to explicitly build the project in editable mode by default. - if "--use-dist" in s.posargs: - s.run( - "uv", - "pip", - "install", - "--reinstall-package=tabeline", - "--no-index", - "--find-links=dist/", - "--no-deps", - "tabeline", - ) - else: - s.run("uv", "pip", "install", "--no-deps", "--no-build", "-e", ".") +def _run_tests(s: Session, test_name: str, *pytest_args: str): + # Almost all of this code is here to provide code coverage for the Rust + # code, which requires instrumentation and post-processing + + # Set env so cargo-llvm-cov instruments the Rust build and writes profraw + # files to its expected target dir. + # This command spits out shell commmands to set variables, so they must be + # parsed, somewhat fragilly. + cov_env_output = subprocess.check_output(["cargo", "llvm-cov", "show-env"], text=True) + for line in cov_env_output.splitlines(): + key, _, value = line.partition("=") + s.env[key] = value.strip().strip("'") + + # Install Tabeline into the test virtual environment + # This may seem like a weird way to install the current project, but this is + # the only way to do it so that the Rust code actually builds and also does + # not interfere with other environments. + # Here are some simpler ways to build that do not work and why: + # 1. Do not build with `uv sync` because that is an editable build and that + # won't build the Rust code. + # 2. Do not build with `uv sync --no-editable` because that won't rebuild + # when the project is changed. + # 3. Do not build with `maturin develop --uv` because that will cause the + # intrumentation to be left in dev environment causing raw coverage files + # to be left in working directories every time Python is invoked. + s.run( + "uv", + "pip", + "install", + # Turn off build isolation so that we still get caching of Rust compiles + "--no-build-isolation", + # Force installation so that changes get installed + "--reinstall-package", + "tabeline", + # We already installed dependencies with nox-uv + "--no-deps", + # `uv pip install` triggers a maturin release build, which will put the + # instrumented binary in target/release instead of target/debug where + # `cargo llvm-cov report` expects it + "--config-settings", + "build-args=--profile=dev", + ".", + ) + + coverage_data = f".coverage.{test_name}" + + # Run the tests with coverage + s.run("coverage", "run", "--data-file", coverage_data, "-m", "pytest", *pytest_args) + + # Convert the Python coverage data to the common lcov format + s.run("coverage", "lcov", "--data-file", coverage_data, "-o", f"python.{test_name}.lcov") + Path(coverage_data).unlink() + + # Convert the Rust coverage data to the common lcov format + # This must be done here because the cargo-llvm-cov data is not portable + # (unlike the Python data) + s.run( + "cargo", + "llvm-cov", + "report", + "--lcov", + "--output-path", + f"rust.{test_name}.lcov", + "--ignore-filename-regex", + r"/\.cargo/|/rustc/", + external=True, + ) @session( @@ -34,9 +84,7 @@ def _install_project(s: Session): uv_no_install_project=True, ) def test(s: Session): - _install_project(s) - coverage_file = f".coverage.{platform.machine()}.{platform.system()}.{s.python}" - s.run("coverage", "run", "--data-file", coverage_file, "-m", "pytest") + _run_tests(s, f"{platform.machine()}.{platform.system()}.{s.python}") @session( @@ -46,9 +94,11 @@ def test(s: Session): uv_no_install_project=True, ) def test_polars(s: Session): - _install_project(s) - coverage_file = f".coverage.{platform.machine()}.{platform.system()}.{s.python}.polars" - s.run("coverage", "run", "--data-file", coverage_file, "-m", "pytest", "tests/test_polars.py") + _run_tests( + s, + f"{platform.machine()}.{platform.system()}.{s.python}.polars", + "tests/test_polars.py", + ) @session( @@ -58,16 +108,25 @@ def test_polars(s: Session): uv_no_install_project=True, ) def test_pandas(s: Session): - _install_project(s) - coverage_file = f".coverage.{platform.machine()}.{platform.system()}.{s.python}.pandas" - s.run("coverage", "run", "--data-file", coverage_file, "-m", "pytest", "tests/test_pandas.py") + _run_tests( + s, + f"{platform.machine()}.{platform.system()}.{s.python}.pandas", + "tests/test_pandas.py", + ) @session(uv_only_groups=["test"]) def coverage(s: Session): - s.run("coverage", "combine") - s.run("coverage", "html") - s.run("coverage", "xml") + lcovs = sorted(Path().glob("python.*.lcov")) + sorted(Path().glob("rust.*.lcov")) + add_args = [arg for p in lcovs for arg in ("-a", str(p))] + s.run("lcov", *add_args, "-o", "combined.lcov", external=True) + s.run( + "genhtml", + "combined.lcov", + "-o", + "htmlcov", + external=True, + ) @session(uv_only_groups=["lint"]) diff --git a/pyproject.toml b/pyproject.toml index 40239a3..91db311 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,7 @@ nox = [ ] test = [ + "maturin == 1.*", "pytest == 9.*", "coverage", ] diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 32c68ee..e498309 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] channel = "1.94.1" -components = ["rustfmt", "clippy"] +components = ["rustfmt", "clippy", "llvm-tools-preview"] diff --git a/uv.lock b/uv.lock index 8d81fab..758087e 100644 --- a/uv.lock +++ b/uv.lock @@ -500,6 +500,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "maturin" +version = "1.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/16/b284a7bc4af3dd87717c784278c1b8cb18606ad1f6f7a671c47bfd9c3df0/maturin-1.13.1.tar.gz", hash = "sha256:9a87ff3b8e4d1c6eac33ebfe8e261e8236516d98d45c0323550621819b5a1a2f", size = 340369, upload-time = "2026-04-09T15:14:07.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/4d/a23fc95be881aa8c7a6ea353410417872e4d7065df03d7f3db8f0dbed4a7/maturin-1.13.1-py3-none-linux_armv6l.whl", hash = "sha256:416e4e01cb88b798e606ee43929df897e42c1647b722ef68283816cca99a8742", size = 10102444, upload-time = "2026-04-09T15:13:48.393Z" }, + { url = "https://files.pythonhosted.org/packages/a6/1e/65c385d65bae95cf04895d52f39dbed8b1453ae55da2903d252ade40a774/maturin-1.13.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:72888e87819ce546d0d2df900e4b385e4ef299077d92ee37b48923a5602dae94", size = 19576043, upload-time = "2026-04-09T15:14:08.685Z" }, + { url = "https://files.pythonhosted.org/packages/8f/13/f6bc868d0bfecd9314870b97f530a167e31f7878ac4945c78245c6eef69c/maturin-1.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:98b5fcf1a186c217830a8295ecc2989c6b1cf50945417adfc15252107b9475b7", size = 10117339, upload-time = "2026-04-09T15:13:40.559Z" }, + { url = "https://files.pythonhosted.org/packages/51/58/279e081305c11c1c1c4fccacf77df8959646c5d4de7a57ec7e787653e270/maturin-1.13.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:3da18cccf2f683c0977bff9146a0908d6ffce836d600665736ac01679f588cb9", size = 10139689, upload-time = "2026-04-09T15:13:38.291Z" }, + { url = "https://files.pythonhosted.org/packages/00/94/69391af5396c6aab723932240803f49e5f3de3dd7c57d32f02d237a0ce32/maturin-1.13.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:6b1e5916a253243e8f5f9e847b62bbc98420eec48c9ce2e2e8724c6da89d359b", size = 10551141, upload-time = "2026-04-09T15:13:42.887Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bf/4edac2667b49e3733438062ae416413b8fc8d42e1bd499ba15e1fb02fc55/maturin-1.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:dc91031e0619c1e28730279ef9ee5f106c9b9ec806b013f888676b242f892eb7", size = 9983094, upload-time = "2026-04-09T15:13:56.868Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/a6d651cfe8fc6bf2e892c90e3cdbb25c06d81c9115140d03ea1a68a97575/maturin-1.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:001741c6cff56aa8ea59a0d78ae990c0550d0e3e82b00b683eedb4158a8ef7e6", size = 9949980, upload-time = "2026-04-09T15:13:59.185Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d1/82c067464f848e38af9910bce55eb54302b1c1284a279d515dbfcf5994f5/maturin-1.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:01c845825c917c07c1d0b2c9032c59c16a7d383d1e649a46481d3e5693c2750f", size = 13186276, upload-time = "2026-04-09T15:13:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f4/25367baf1025580f047f9b37598bb3fadc416e24536afd4f28e190335c73/maturin-1.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f69093ed4a0e6464e52a7fc26d714f859ce15630ec8070743398c6bf41f38a9e", size = 10891837, upload-time = "2026-04-09T15:13:35.68Z" }, + { url = "https://files.pythonhosted.org/packages/af/be/caafad8ce74974b7deafdf144d12f758993dfea4c66c9905b138f51a7792/maturin-1.13.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:c1490584f3c70af45466ee99065b49e6657ebdccac6b10571bb44681309c9396", size = 10351032, upload-time = "2026-04-09T15:14:01.632Z" }, + { url = "https://files.pythonhosted.org/packages/66/0e/970a721d27cfa410e8bfa0a1e32e6ef52cb8169692110a5fdabe1af3f570/maturin-1.13.1-py3-none-win32.whl", hash = "sha256:c6a720b252c99de072922dbe4432ab19662b6f80045b0355fec23bdfccb450da", size = 8855465, upload-time = "2026-04-09T15:13:51.122Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/7c1e0d65fa147d5479055a171541c82b8cdfc1c825d85a82240470f14176/maturin-1.13.1-py3-none-win_amd64.whl", hash = "sha256:a2017d2281203d0c6570240e7d746564d766d756105823b7de68bda6ae722711", size = 10230471, upload-time = "2026-04-09T15:13:53.89Z" }, + { url = "https://files.pythonhosted.org/packages/c5/2a/afe0193b673a79ffd2e01ad999511b7e9e6b49af02bb3759d82a78c3043d/maturin-1.13.1-py3-none-win_arm64.whl", hash = "sha256:2839024dcd65776abb4759e5bca29941971e095574162a4d335191da4be9ff24", size = 8905575, upload-time = "2026-04-09T15:14:03.891Z" }, +] + [[package]] name = "mergedeep" version = "1.3.4" @@ -1278,6 +1302,7 @@ nox = [ ] test = [ { name = "coverage" }, + { name = "maturin" }, { name = "pytest" }, ] @@ -1298,6 +1323,7 @@ lint = [{ name = "ruff", specifier = "==0.15.*" }] nox = [{ name = "nox-uv", specifier = "==0.7.*" }] test = [ { name = "coverage" }, + { name = "maturin", specifier = "==1.*" }, { name = "pytest", specifier = "==9.*" }, ] From 54a7d4656f6ef54cfc3b50f48887050d1f742fc2 Mon Sep 17 00:00:00 2001 From: David Hagen Date: Sat, 25 Apr 2026 09:43:37 -0400 Subject: [PATCH 2/5] Install cargo-llvm-cov on CI --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08bd8e2..5f53b73 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,8 @@ jobs: uses: actions-rust-lang/setup-rust-toolchain@v1 with: rustflags: "" + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov - name: Install uv uses: astral-sh/setup-uv@v5 with: From 808b7c5be80c246bc816ac867b00c74f30a0b98e Mon Sep 17 00:00:00 2001 From: David Hagen Date: Sat, 25 Apr 2026 14:45:23 -0400 Subject: [PATCH 3/5] Try to use relative paths --- noxfile.py | 24 ++++++++++++++++++++---- pyproject.toml | 4 ++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/noxfile.py b/noxfile.py index 20dade2..b8497ad 100644 --- a/noxfile.py +++ b/noxfile.py @@ -17,7 +17,13 @@ def _run_tests(s: Session, test_name: str, *pytest_args: str): # files to its expected target dir. # This command spits out shell commmands to set variables, so they must be # parsed, somewhat fragilly. - cov_env_output = subprocess.check_output(["cargo", "llvm-cov", "show-env"], text=True) + # --remap-path-prefix injects rustc's --remap-path-prefix=$workspace= flag + # into the build, so SF: paths in the resulting lcov are repo-relative and + # therefore portable across OS runners. The matching flag is also passed + # to `cargo llvm-cov report` below. + cov_env_output = subprocess.check_output( + ["cargo", "llvm-cov", "show-env", "--remap-path-prefix"], text=True + ) for line in cov_env_output.splitlines(): key, _, value = line.partition("=") s.env[key] = value.strip().strip("'") @@ -58,13 +64,22 @@ def _run_tests(s: Session, test_name: str, *pytest_args: str): # Run the tests with coverage s.run("coverage", "run", "--data-file", coverage_data, "-m", "pytest", *pytest_args) + # Combine the data file into itself to apply [tool.coverage.paths] aliasing, + # which remaps the nox-installed site-packages location back to the source + # tree (e.g. .nox/.../site-packages/tabeline/ -> python/tabeline/). Without + # this step `coverage lcov` would emit the literal recorded path, which + # varies per OS and Python version and breaks cross-runner combining. + combined_data = f"{coverage_data}.combined" + s.run("coverage", "combine", "--data-file", combined_data, coverage_data) + # Convert the Python coverage data to the common lcov format - s.run("coverage", "lcov", "--data-file", coverage_data, "-o", f"python.{test_name}.lcov") - Path(coverage_data).unlink() + s.run("coverage", "lcov", "--data-file", combined_data, "-o", f"python.{test_name}.lcov") + Path(combined_data).unlink() # Convert the Rust coverage data to the common lcov format # This must be done here because the cargo-llvm-cov data is not portable - # (unlike the Python data) + # (unlike the Python data). --remap-path-prefix strips the workspace root + # from SF: paths so the lcov is portable across OS runners. s.run( "cargo", "llvm-cov", @@ -74,6 +89,7 @@ def _run_tests(s: Session, test_name: str, *pytest_args: str): f"rust.{test_name}.lcov", "--ignore-filename-regex", r"/\.cargo/|/rustc/", + "--remap-path-prefix", external=True, ) diff --git a/pyproject.toml b/pyproject.toml index 91db311..e20a07b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,10 @@ filterwarnings = ["error"] [tool.coverage.run] branch = true source = ["tabeline"] +# Record relative paths so coverage data combines cleanly across OS runners +# and so [tool.coverage.paths] aliasing maps the nox-installed site-packages +# location back to the source tree. +relative_files = true [tool.coverage.report] exclude_lines = [ From 2df973fb2dde7a42b4117c7d55294b6f2cac684a Mon Sep 17 00:00:00 2001 From: David Hagen Date: Sat, 25 Apr 2026 21:10:44 -0400 Subject: [PATCH 4/5] Rewrite Windows paths --- noxfile.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/noxfile.py b/noxfile.py index b8497ad..4b4b5a3 100644 --- a/noxfile.py +++ b/noxfile.py @@ -80,19 +80,32 @@ def _run_tests(s: Session, test_name: str, *pytest_args: str): # This must be done here because the cargo-llvm-cov data is not portable # (unlike the Python data). --remap-path-prefix strips the workspace root # from SF: paths so the lcov is portable across OS runners. + rust_lcov = Path(f"rust.{test_name}.lcov") s.run( "cargo", "llvm-cov", "report", "--lcov", "--output-path", - f"rust.{test_name}.lcov", + str(rust_lcov), "--ignore-filename-regex", r"/\.cargo/|/rustc/", "--remap-path-prefix", external=True, ) + # On Windows, cargo-llvm-cov writes SF: paths with backslashes, which break + # genhtml when the lcov is later merged on Linux (backslashes get treated + # as literal filename characters). Neither cargo-llvm-cov nor llvm-cov has + # a flag to force forward slashes, so rewrite SF: lines in place. + rust_lcov.write_text( + "\n".join( + "SF:" + line[3:].replace("\\", "/") if line.startswith("SF:") else line + for line in rust_lcov.read_text().splitlines() + ) + + "\n" + ) + @session( python=["3.10", "3.11", "3.12", "3.13", "3.14"], From 04c7e03e7d0192e3cfeef73edeb21e53b8df5a3d Mon Sep 17 00:00:00 2001 From: David Hagen Date: Sun, 26 Apr 2026 21:57:45 -0400 Subject: [PATCH 5/5] Normalize Windows line endings for Python too --- noxfile.py | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/noxfile.py b/noxfile.py index 4b4b5a3..fb6114d 100644 --- a/noxfile.py +++ b/noxfile.py @@ -9,6 +9,20 @@ options.sessions = ["test", "test_polars", "test_pandas", "coverage", "lint"] +def _normalize_lcov_separators(lcov: Path) -> None: + # On Windows, both coverage.py and cargo-llvm-cov write SF: paths with + # backslashes, which break genhtml when the lcov is later merged on Linux + # (backslashes get treated as literal filename characters). Neither tool + # has a flag to force forward slashes, so rewrite SF: lines in place. + lcov.write_text( + "\n".join( + "SF:" + line[3:].replace("\\", "/") if line.startswith("SF:") else line + for line in lcov.read_text().splitlines() + ) + + "\n" + ) + + def _run_tests(s: Session, test_name: str, *pytest_args: str): # Almost all of this code is here to provide code coverage for the Rust # code, which requires instrumentation and post-processing @@ -73,8 +87,10 @@ def _run_tests(s: Session, test_name: str, *pytest_args: str): s.run("coverage", "combine", "--data-file", combined_data, coverage_data) # Convert the Python coverage data to the common lcov format - s.run("coverage", "lcov", "--data-file", combined_data, "-o", f"python.{test_name}.lcov") + python_lcov = Path(f"python.{test_name}.lcov") + s.run("coverage", "lcov", "--data-file", combined_data, "-o", str(python_lcov)) Path(combined_data).unlink() + _normalize_lcov_separators(python_lcov) # Convert the Rust coverage data to the common lcov format # This must be done here because the cargo-llvm-cov data is not portable @@ -94,17 +110,7 @@ def _run_tests(s: Session, test_name: str, *pytest_args: str): external=True, ) - # On Windows, cargo-llvm-cov writes SF: paths with backslashes, which break - # genhtml when the lcov is later merged on Linux (backslashes get treated - # as literal filename characters). Neither cargo-llvm-cov nor llvm-cov has - # a flag to force forward slashes, so rewrite SF: lines in place. - rust_lcov.write_text( - "\n".join( - "SF:" + line[3:].replace("\\", "/") if line.startswith("SF:") else line - for line in rust_lcov.read_text().splitlines() - ) - + "\n" - ) + _normalize_lcov_separators(rust_lcov) @session(