Skip to content
Open
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
35 changes: 19 additions & 16 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ 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 cargo-llvm-cov
uses: taiki-e/install-action@cargo-llvm-cov
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
Expand All @@ -45,20 +45,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:
Expand All @@ -67,6 +65,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:
Expand All @@ -80,21 +80,24 @@ 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:
runs-on: ubuntu-24.04
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:
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ python/**/*.so

# coverage
/.coverage*
/coverage.xml
/*.lcov
/htmlcov

# mkdocs
/site
Expand Down
28 changes: 26 additions & 2 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
154 changes: 124 additions & 30 deletions noxfile.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import platform
import subprocess
from pathlib import Path

from nox import Session, options, parametrize
from nox_uv import session
Expand All @@ -7,25 +9,108 @@
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",
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()
)
else:
s.run("uv", "pip", "install", "--no-deps", "--no-build", "-e", ".")
+ "\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

# 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.
# --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("'")

# 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)

# 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
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
# (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",
str(rust_lcov),
"--ignore-filename-regex",
r"/\.cargo/|/rustc/",
"--remap-path-prefix",
external=True,
)

_normalize_lcov_separators(rust_lcov)


@session(
Expand All @@ -34,9 +119,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(
Expand All @@ -46,9 +129,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(
Expand All @@ -58,16 +143,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"])
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ nox = [
]

test = [
"maturin == 1.*",
"pytest == 9.*",
"coverage",
]
Expand Down Expand Up @@ -85,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 = [
Expand Down
2 changes: 1 addition & 1 deletion rust-toolchain.toml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
[toolchain]
channel = "1.94.1"
components = ["rustfmt", "clippy"]
components = ["rustfmt", "clippy", "llvm-tools-preview"]
Loading
Loading