Add code coverage + HTML report machinery

This adds a new `cargo dev coverage` subcommand, which invokes nextest
under our new coverage pesudo-profile. This profile in turn enables
`-C instrument-coverage`.

`cargo dev coverage` also takes care of merging raw profiling
samples into profdata and LCOV reports for us, which our
new `coverage-html-report.py` script can turn into a readable
(but very large) HTML report.

The basic flow for this is:

```
$ # any nextest flags will work
$ cargo dev coverage ...
```

That will output something like:

```
Merged profile: .../astral-sh/uv/target/coverage/profdata/b46cc0f3-7cd0-4309-9df2-10ab653e6dd8.profdata
LCOV profile: .../astral-sh/uv/target/coverage/lcov/b46cc0f3-7cd0-4309-9df2-10ab653e6dd8.lcov
Generate an HTML report with: uv run --script scripts/coverage-html-report.py b46cc0f3-7cd0-4309-9df2-10ab653e6dd8
```

And then you can run:

```
uv run --script scripts/coverage-html-report.py b46cc0f3-7cd0-4309-9df2-10ab653e6dd8
```

(or add `--open` to auto-open in the browser).

Under the hood, this only uses existing dependencies plus
Rust's own LLVM toolchain, plus coverage.py for the HTML
report generation.

Signed-off-by: William Woodruff <william@yossarian.net>

MVP coverage workflow

Signed-off-by: William Woodruff <william@yossarian.net>

MVP coverage workflow, take 2

Signed-off-by: William Woodruff <william@yossarian.net>

Use a pool of 16 raw profiles for pooling

Signed-off-by: William Woodruff <william@yossarian.net>

Avoid some Windows UNC pain

Signed-off-by: William Woodruff <william@yossarian.net>

Fix rustflags for coverage on Windows

Signed-off-by: William Woodruff <william@yossarian.net>

Remove coverage ID chum

Signed-off-by: William Woodruff <william@yossarian.net>

Avoid Windows nonsense

Signed-off-by: William Woodruff <william@yossarian.net>

Add coverage guide to CONTRIBUTING

Signed-off-by: William Woodruff <william@yossarian.net>

Be less strict about the ID

Signed-off-by: William Woodruff <william@yossarian.net>

Prettier

Signed-off-by: William Woodruff <william@yossarian.net>
This commit is contained in:
William Woodruff
2026-06-05 15:00:41 -04:00
parent 1ed52dc791
commit 3c38995c6e
9 changed files with 775 additions and 19 deletions
+8
View File
@@ -0,0 +1,8 @@
[build]
rustflags = ["-C", "instrument-coverage"]
target-dir = "target/coverage"
# Target-specific rustflags take precedence over build.rustflags. Repeat the
# instrumentation flag so it is combined with Windows' static CRT flag.
[target.'cfg(all(target_env = "msvc", target_os = "windows"))']
rustflags = ["-C", "instrument-coverage"]
+18
View File
@@ -0,0 +1,18 @@
name: Coverage
on:
pull_request:
types: [labeled]
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref_name }}-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
test:
if: ${{ github.event.label.name == 'coverage' }}
uses: ./.github/workflows/test.yml
with:
save-rust-cache: "false"
+166 -19
View File
@@ -18,6 +18,7 @@ env:
CARGO_TERM_COLOR: always
PYTHON_VERSION: "3.12"
RUSTUP_MAX_RETRIES: 10
COLLECT_COVERAGE: ${{ github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'coverage') }}
jobs:
# We use the large GitHub actions runners
@@ -25,7 +26,7 @@ jobs:
# See: https://docs.github.com/en/actions/using-github-hosted-runners/about-larger-runners/about-larger-runners#about-ubuntu-and-windows-larger-runners
cargo-test-linux:
timeout-minutes: 10
timeout-minutes: ${{ (github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'coverage')) && 20 || 10 }}
runs-on: depot-ubuntu-24.04-16
name: "cargo test on linux"
steps:
@@ -47,6 +48,10 @@ jobs:
with:
version: "0.11.21"
- name: "Install LLVM tools"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
run: rustup component add llvm-tools
- name: "Install required Python versions"
run: uv python install
@@ -90,6 +95,7 @@ jobs:
tool: cargo-nextest
- name: "Cargo test"
id: test
env:
# Retry more than default to reduce flakes in CI
UV_HTTP_RETRIES: 5
@@ -102,11 +108,53 @@ jobs:
INSTA_UPDATE: new
INSTA_PENDING_DIR: ${{ github.workspace }}/pending-snapshots
run: |
cargo nextest run \
--cargo-profile fast-build \
--features test-python-patch,native-auth,secret-service \
--workspace \
test_args=(
--cargo-profile fast-build
--features test-python-patch,native-auth,secret-service
--workspace
--profile ci-linux
)
if [[ "$COLLECT_COVERAGE" == "true" ]]; then
tracking_id="${GITHUB_SHA:0:16}"
cargo dev coverage --id "$tracking_id" -- "${test_args[@]}"
echo "tracking_id=$tracking_id" >> "$GITHUB_OUTPUT"
else
cargo nextest run "${test_args[@]}"
fi
- name: "Generate HTML coverage report"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
env:
COVERAGE_ID: ${{ steps.test.outputs.tracking_id }}
run: |
uv run --script scripts/coverage-html-report.py "$COVERAGE_ID"
test -f "target/coverage/lcov/$COVERAGE_ID.lcov"
test -f "target/coverage/html/$COVERAGE_ID/index.html"
- name: "Upload coverage report"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
id: upload-coverage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-linux-${{ github.sha }}
path: |
target/coverage/html/${{ steps.test.outputs.tracking_id }}/
target/coverage/lcov/${{ steps.test.outputs.tracking_id }}.lcov
if-no-files-found: error
retention-days: 14
- name: "Summarize coverage"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
env:
ARTIFACT_URL: ${{ steps.upload-coverage.outputs.artifact-url }}
COVERAGE_ID: ${{ steps.test.outputs.tracking_id }}
run: |
{
echo "## Linux coverage"
echo
echo "- Tracking ID: \`$COVERAGE_ID\`"
echo "- Artifact: [$ARTIFACT_URL]($ARTIFACT_URL)"
} >> "$GITHUB_STEP_SUMMARY"
- name: "Upload pending snapshots"
if: ${{ failure() }}
@@ -128,9 +176,9 @@ jobs:
retention-days: 14
cargo-test-macos:
timeout-minutes: 20
timeout-minutes: ${{ (github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'coverage')) && 30 || 20 }}
# Only run macOS tests on main without opt-in
if: ${{ inputs.test-macos == 'true' }}
if: ${{ inputs.test-macos == 'true' || github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'coverage') }}
runs-on: depot-macos-15
name: "cargo test on macos"
steps:
@@ -145,6 +193,10 @@ jobs:
- name: "Install Rust toolchain"
run: rustup show
- name: "Install LLVM tools"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
run: rustup component add llvm-tools
- name: "Create HFS+ disk image (no reflink support)"
run: |
hdiutil create -size 256m -fs HFS+ -volname NoReflink /tmp/noreflink.dmg
@@ -164,6 +216,7 @@ jobs:
tool: cargo-nextest
- name: "Cargo test"
id: test
env:
# Retry more than default to reduce flakes in CI
UV_HTTP_RETRIES: 5
@@ -177,12 +230,54 @@ jobs:
INSTA_UPDATE: new
INSTA_PENDING_DIR: ${{ github.workspace }}/pending-snapshots
run: |
cargo nextest run \
--cargo-profile fast-build \
--no-default-features \
--features test-python,test-python-managed,test-pypi,test-git,test-git-lfs,performance,test-crates-io,native-auth,apple-native \
--workspace \
test_args=(
--cargo-profile fast-build
--no-default-features
--features test-python,test-python-managed,test-pypi,test-git,test-git-lfs,performance,test-crates-io,native-auth,apple-native
--workspace
--profile ci-macos
)
if [[ "$COLLECT_COVERAGE" == "true" ]]; then
tracking_id="${GITHUB_SHA:0:16}"
cargo dev coverage --id "$tracking_id" -- "${test_args[@]}"
echo "tracking_id=$tracking_id" >> "$GITHUB_OUTPUT"
else
cargo nextest run "${test_args[@]}"
fi
- name: "Generate HTML coverage report"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
env:
COVERAGE_ID: ${{ steps.test.outputs.tracking_id }}
run: |
uv run --script scripts/coverage-html-report.py "$COVERAGE_ID"
test -f "target/coverage/lcov/$COVERAGE_ID.lcov"
test -f "target/coverage/html/$COVERAGE_ID/index.html"
- name: "Upload coverage report"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
id: upload-coverage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-macos-${{ github.sha }}
path: |
target/coverage/html/${{ steps.test.outputs.tracking_id }}/
target/coverage/lcov/${{ steps.test.outputs.tracking_id }}.lcov
if-no-files-found: error
retention-days: 14
- name: "Summarize coverage"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
env:
ARTIFACT_URL: ${{ steps.upload-coverage.outputs.artifact-url }}
COVERAGE_ID: ${{ steps.test.outputs.tracking_id }}
run: |
{
echo "## macOS coverage"
echo
echo "- Tracking ID: \`$COVERAGE_ID\`"
echo "- Artifact: [$ARTIFACT_URL]($ARTIFACT_URL)"
} >> "$GITHUB_STEP_SUMMARY"
- name: "Upload pending snapshots"
if: ${{ failure() }}
@@ -204,7 +299,7 @@ jobs:
retention-days: 14
cargo-test-windows:
timeout-minutes: 15
timeout-minutes: ${{ (github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'coverage')) && 30 || 15 }}
runs-on: namespace-profile-windows-2022-x86-64-16
name: "cargo test on windows ${{ matrix.partition }} of 3"
strategy:
@@ -242,6 +337,11 @@ jobs:
working-directory: ${{ env.UV_WORKSPACE }}
run: rustup show
- name: "Install LLVM tools"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
working-directory: ${{ env.UV_WORKSPACE }}
run: rustup component add llvm-tools
- name: "Create NTFS test directory (low hardlink limit)"
run: New-Item -Path "C:\uv" -ItemType Directory -Force
@@ -256,6 +356,7 @@ jobs:
tool: cargo-nextest
- name: "Cargo test"
id: test
working-directory: ${{ env.UV_WORKSPACE }}
env:
# Retry more than default to reduce flakes in CI
@@ -272,13 +373,59 @@ jobs:
INSTA_PENDING_DIR: ${{ github.workspace }}/pending-snapshots
shell: bash
run: |
cargo nextest run \
--cargo-profile fast-build \
--no-default-features \
--features test-python,test-pypi,test-python-managed,test-windows-registry,native-auth,windows-native \
--workspace \
--profile ci-windows \
test_args=(
--cargo-profile fast-build
--no-default-features
--features test-python,test-pypi,test-python-managed,test-windows-registry,native-auth,windows-native
--workspace
--profile ci-windows
--partition hash:${{ matrix.partition }}/3
)
if [[ "$COLLECT_COVERAGE" == "true" ]]; then
tracking_id="${GITHUB_SHA:0:16}"
cargo dev coverage --id "$tracking_id" -- "${test_args[@]}"
echo "tracking_id=$tracking_id" >> "$GITHUB_OUTPUT"
else
cargo nextest run "${test_args[@]}"
fi
- name: "Generate HTML coverage report"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
working-directory: ${{ env.UV_WORKSPACE }}
shell: bash
env:
COVERAGE_ID: ${{ steps.test.outputs.tracking_id }}
run: |
uv run --script scripts/coverage-html-report.py "$COVERAGE_ID"
test -f "target/coverage/lcov/$COVERAGE_ID.lcov"
test -f "target/coverage/html/$COVERAGE_ID/index.html"
- name: "Upload coverage report"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
id: upload-coverage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-windows-${{ matrix.partition }}-${{ github.sha }}
path: |
${{ env.UV_WORKSPACE }}/target/coverage/html/${{ steps.test.outputs.tracking_id }}/
${{ env.UV_WORKSPACE }}/target/coverage/lcov/${{ steps.test.outputs.tracking_id }}.lcov
if-no-files-found: error
retention-days: 14
- name: "Summarize coverage"
if: ${{ env.COLLECT_COVERAGE == 'true' }}
working-directory: ${{ env.UV_WORKSPACE }}
shell: bash
env:
ARTIFACT_URL: ${{ steps.upload-coverage.outputs.artifact-url }}
COVERAGE_ID: ${{ steps.test.outputs.tracking_id }}
run: |
{
echo "## Windows coverage partition ${{ matrix.partition }}"
echo
echo "- Tracking ID: \`$COVERAGE_ID\`"
echo "- Artifact: [$ARTIFACT_URL]($ARTIFACT_URL)"
} >> "$GITHUB_STEP_SUMMARY"
- name: "Upload pending snapshots"
if: ${{ failure() }}
+55
View File
@@ -247,6 +247,61 @@ uv run resolver \
../test/requirements/trio.in
```
## Code coverage
You can run uv's unit and integration tests under instrumentation to get a sense for which APIs
currently lack test coverage.
To performan a coverage-instrumented run, you'll need some additional LLVM tools. We strongly
recommend adding these via `rustup` so that they match your current Rust toolchain's LLVM version:
```shell
rustup component add llvm-tools
```
From there, you can run `cargo dev coverage` to run the tests with coverage. `cargo dev coverage`
runs `nextest` under the hood, so you can limit a coverage run like you would for normal test runs.
Examples:
```shell
# run all tests under coverage
cargo dev coverage
# run just the uv-audit member's tests
cargo dev coverage -- -p uv-audit
# optional: pass an explicit tracking ID instead of generating one
cargo dev coverage --id demo
```
By default, `cargo dev coverage` will generate a tracking ID and print it; you'll see something like
this:
```console
Coverage tracking ID: WuSYqZVUl0R9YPka
Raw profiles: /Users/ww/oss/astral-sh/uv/target/coverage/profraw/WuSYqZVUl0R9YPka
[... normal test output ...]
[... normal test output ...]
[... normal test output ...]
Merged profile: /Users/ww/oss/astral-sh/uv/target/coverage/profdata/WuSYqZVUl0R9YPka.profdata
LCOV profile: /Users/ww/oss/astral-sh/uv/target/coverage/lcov/WuSYqZVUl0R9YPka.lcov
Generate an HTML report with: uv run --script scripts/coverage-html-report.py WuSYqZVUl0R9YPka
```
Then, to turn your run's coverage into a human-readable HTML report:
```shell
uv run --script scripts/coverage-html-report.py TRACKING_ID
# optional: open the HTML report in the default browser
uv run --script scripts/coverage-html-report.py --open TRACKING_ID
```
Note that HTML reports can be very large, and may take some time to render in your browser.
### Analyzing concurrency
You can use [tracing-durations-export](https://github.com/konstin/tracing-durations-export) to
Generated
+1
View File
@@ -6253,6 +6253,7 @@ dependencies = [
"uv-distribution-types",
"uv-errors",
"uv-extract",
"uv-fastid",
"uv-git",
"uv-installer",
"uv-macros",
+1
View File
@@ -63,6 +63,7 @@ tokio-util = { workspace = true }
tracing = { workspace = true }
tracing-durations-export = { workspace = true, features = ["plot"] }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
uv-fastid = { workspace = true }
uv-performance-memory-allocator = { path = "../uv-performance-memory-allocator", optional = true }
walkdir = { workspace = true }
+341
View File
@@ -0,0 +1,341 @@
use std::env;
use std::ffi::OsString;
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anstream::println;
use anyhow::{Context, Result, bail};
use clap::Parser;
use serde_json::Value;
use uv_fastid::Id;
use crate::ROOT_DIR;
const CARGO_MESSAGE_REASONS: [&str; 4] = [
"compiler-artifact",
"compiler-message",
"build-script-executed",
"build-finished",
];
fn parse_coverage_id(value: &str) -> Result<String, String> {
if value.is_empty()
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
return Err(format!(
"invalid ID: {value:?} (must be non-empty and use only [A-Za-z0-9-_])"
));
}
Ok(value.to_string())
}
#[derive(Parser)]
pub(crate) struct CoverageArgs {
/// The ID to use for the coverage run.
#[arg(long, value_parser = parse_coverage_id)]
id: Option<String>,
/// Additional arguments to pass to `cargo nextest run`.
#[arg(last = true)]
nextest_args: Vec<OsString>,
}
pub(crate) fn coverage(args: CoverageArgs) -> Result<()> {
let root = fs_err::canonicalize(ROOT_DIR).context("failed to locate the workspace root")?;
let llvm_tools = find_llvm_tools(&root)?;
let tracking_id = args.id.unwrap_or_else(|| Id::insecure().to_string());
let raw_profiles = root
.join("target")
.join("coverage")
.join("profraw")
.join(&tracking_id);
let merged_profiles = root.join("target").join("coverage").join("profdata");
let merged_profile = merged_profiles.join(format!("{tracking_id}.profdata"));
let lcov_profiles = root.join("target").join("coverage").join("lcov");
let lcov_profile = lcov_profiles.join(format!("{tracking_id}.lcov"));
fs_err::create_dir_all(&raw_profiles)
.with_context(|| format!("failed to create `{}`", raw_profiles.display()))?;
println!("Coverage tracking ID: {tracking_id}");
println!("Raw profiles: {}", raw_profiles.display());
// Bound disk usage while retaining enough merge slots for concurrent test processes.
let profile_pattern = raw_profiles.join("%16m.profraw");
let mut child = Command::new("cargo")
.args([
"nextest",
"run",
"--config",
".cargo/coverage.toml",
"--cargo-message-format",
"json-render-diagnostics",
])
.args(args.nextest_args)
.current_dir(&root)
.env("LLVM_PROFILE_FILE", profile_pattern)
.stdout(Stdio::piped())
.spawn()
.context("failed to run `cargo nextest run`")?;
let Some(stdout) = child.stdout.take() else {
bail!("failed to read output from `cargo nextest run`");
};
let binaries = collect_coverage_binaries(BufReader::new(stdout))?;
let status = child
.wait()
.context("failed to wait for `cargo nextest run`")?;
if !status.success() {
bail!("`cargo nextest run` failed with {status}");
}
let profraw_files = collect_profraw_files(&raw_profiles)?;
if profraw_files.is_empty() {
bail!(
"`cargo nextest run` produced no raw coverage profiles in `{}`",
raw_profiles.display()
);
}
fs_err::create_dir_all(&merged_profiles)
.with_context(|| format!("failed to create `{}`", merged_profiles.display()))?;
// Use an input file to avoid exceeding the Windows command-line length limit when there are
// many raw profiles.
let mut profraw_file_list = tempfile::NamedTempFile::new_in(&merged_profiles)
.context("failed to create temporary raw profile list")?;
for profraw_file in &profraw_files {
writeln!(profraw_file_list, "{}", profraw_file.display())?;
}
profraw_file_list
.flush()
.context("failed to flush temporary raw profile list")?;
let input_files_argument = format!("--input-files={}", profraw_file_list.path().display());
let status = Command::new(&llvm_tools.profdata)
.args(["merge", "-sparse"])
.arg(input_files_argument)
.arg("-o")
.arg(&merged_profile)
.status()
.with_context(|| format!("failed to run `{}`", llvm_tools.profdata.display()))?;
if !status.success() {
bail!("`llvm-profdata merge` failed with {status}");
}
println!("Merged profile: {}", merged_profile.display());
if binaries.is_empty() {
bail!("`cargo nextest run` produced no executable artifacts for coverage export");
}
fs_err::create_dir_all(&lcov_profiles)
.with_context(|| format!("failed to create `{}`", lcov_profiles.display()))?;
export_lcov(
&llvm_tools.cov,
&merged_profile,
&binaries,
&root,
&lcov_profile,
)?;
println!("LCOV profile: {}", lcov_profile.display());
println!(
"Generate an HTML report with: uv run --script scripts/coverage-html-report.py {tracking_id}"
);
Ok(())
}
fn collect_coverage_binaries(reader: impl BufRead) -> Result<Vec<PathBuf>> {
let mut binaries = Vec::new();
let mut stdout = std::io::stdout().lock();
for line in reader.lines() {
let line = line.context("failed to read output from `cargo nextest run`")?;
let Ok(message) = serde_json::from_str::<Value>(&line) else {
writeln!(stdout, "{line}")?;
stdout.flush()?;
continue;
};
let Some(reason) = message.get("reason").and_then(Value::as_str) else {
writeln!(stdout, "{line}")?;
stdout.flush()?;
continue;
};
if reason == "compiler-artifact"
&& let Some(executable) = message.get("executable").and_then(Value::as_str)
{
let is_test = message
.pointer("/profile/test")
.and_then(Value::as_bool)
.unwrap_or(false);
let is_binary = message
.pointer("/target/kind")
.and_then(Value::as_array)
.is_some_and(|kinds| kinds.iter().any(|kind| kind.as_str() == Some("bin")));
if is_test || is_binary {
binaries.push(PathBuf::from(executable));
}
}
if !CARGO_MESSAGE_REASONS.contains(&reason) {
writeln!(stdout, "{line}")?;
stdout.flush()?;
}
}
binaries.sort_unstable();
binaries.dedup();
Ok(binaries)
}
fn collect_profraw_files(raw_profiles: &Path) -> Result<Vec<PathBuf>> {
let mut profraw_files = Vec::new();
for entry in fs_err::read_dir(raw_profiles)
.with_context(|| format!("failed to read `{}`", raw_profiles.display()))?
{
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_file()
&& path
.extension()
.is_some_and(|extension| extension == "profraw")
{
profraw_files.push(path);
}
}
profraw_files.sort_unstable();
Ok(profraw_files)
}
fn export_lcov(
llvm_cov: &Path,
merged_profile: &Path,
binaries: &[PathBuf],
root: &Path,
lcov_profile: &Path,
) -> Result<()> {
let Some((binary, additional_binaries)) = binaries.split_first() else {
bail!("no executable artifacts were provided for coverage export");
};
let mut command = Command::new(llvm_cov);
command
.arg("export")
.arg(binary)
.arg(format!("--instr-profile={}", merged_profile.display()))
.args([
r"--ignore-filename-regex=[/\\]\.cargo[/\\]",
r"--ignore-filename-regex=[/\\]rustc[/\\]",
r"--ignore-filename-regex=[/\\]\.rustup[/\\]toolchains[/\\]",
r"--ignore-filename-regex=[/\\]target[/\\]",
"--format=lcov",
])
.stdout(Stdio::piped());
for binary in additional_binaries {
command.arg("-object").arg(binary);
}
let mut child = command
.spawn()
.with_context(|| format!("failed to run `{}`", llvm_cov.display()))?;
let Some(stdout) = child.stdout.take() else {
bail!("failed to read output from `llvm-cov export`");
};
let mut temp_file = tempfile::NamedTempFile::new_in(
lcov_profile
.parent()
.context("LCOV output path must have a parent")?,
)
.context("failed to create temporary LCOV output")?;
for line in BufReader::new(stdout).lines() {
let line = line.context("failed to read output from `llvm-cov export`")?;
if let Some(source) = line.strip_prefix("SF:") {
let source = Path::new(source);
if let Ok(relative) = source.strip_prefix(root) {
writeln!(
temp_file,
"SF:{}",
relative.to_string_lossy().replace('\\', "/")
)?;
} else {
writeln!(
temp_file,
"SF:{}",
source.to_string_lossy().replace('\\', "/")
)?;
}
} else {
writeln!(temp_file, "{line}")?;
}
}
let status = child
.wait()
.context("failed to wait for `llvm-cov export`")?;
if !status.success() {
bail!("`llvm-cov export` failed with {status}");
}
temp_file.flush().context("failed to flush LCOV output")?;
temp_file
.persist(lcov_profile)
.map_err(|error| error.error)
.with_context(|| format!("failed to write `{}`", lcov_profile.display()))?;
Ok(())
}
struct LlvmTools {
profdata: PathBuf,
cov: PathBuf,
}
fn find_llvm_tools(root: &Path) -> Result<LlvmTools> {
let output = Command::new("rustc")
.args(["--print", "target-libdir"])
.current_dir(root)
.output()
.context("failed to run `rustc --print=target-libdir`")?;
if !output.status.success() {
bail!(
"`rustc --print=target-libdir` failed with {}",
output.status
);
}
let target_libdir = String::from_utf8(output.stdout)
.context("`rustc --print=target-libdir` returned a non-UTF-8 path")?;
let target_libdir = Path::new(target_libdir.trim());
let Some(target_dir) = target_libdir.parent() else {
bail!(
"`rustc --print=target-libdir` returned an invalid path: `{}`",
target_libdir.display()
);
};
let tool_dir = target_dir.join("bin");
let profdata = tool_dir.join(format!("llvm-profdata{}", env::consts::EXE_SUFFIX));
let cov = tool_dir.join(format!("llvm-cov{}", env::consts::EXE_SUFFIX));
for tool in [&profdata, &cov] {
if !tool.is_file() {
bail!(
"`{}` was not found at `{}`; install it with `rustup component add llvm-tools`",
tool.file_name().unwrap_or_default().to_string_lossy(),
tool.display()
);
}
}
Ok(LlvmTools { profdata, cov })
}
+5
View File
@@ -8,6 +8,7 @@ use uv_settings::EnvironmentOptions;
use crate::clear_compile::ClearCompileArgs;
use crate::compile::CompileArgs;
use crate::coverage::CoverageArgs;
use crate::generate_all::Args as GenerateAllArgs;
use crate::generate_cli_reference::Args as GenerateCliReferenceArgs;
use crate::generate_env_vars_reference::Args as GenerateEnvVarsReferenceArgs;
@@ -23,6 +24,7 @@ use crate::wheel_metadata::WheelMetadataArgs;
mod clear_compile;
mod compile;
mod coverage;
mod generate_all;
mod generate_cli_reference;
mod generate_env_vars_reference;
@@ -47,6 +49,8 @@ enum Cli {
Compile(CompileArgs),
/// Remove all `.pyc` in the tree.
ClearCompile(ClearCompileArgs),
/// Run tests and collect LLVM coverage profiles.
Coverage(CoverageArgs),
/// List all packages from a Simple API index.
ListPackages(ListPackagesArgs),
/// Run all code and documentation generation steps.
@@ -80,6 +84,7 @@ pub async fn run() -> Result<()> {
Cli::ValidateZip(args) => validate_zip::validate_zip(args, environment).await?,
Cli::Compile(args) => compile::compile(args).await?,
Cli::ClearCompile(args) => clear_compile::clear_compile(&args)?,
Cli::Coverage(args) => coverage::coverage(args)?,
Cli::ListPackages(args) => list_packages::list_packages(args, environment).await?,
Cli::GenerateAll(args) => generate_all::main(&args).await?,
Cli::GenerateJSONSchema(args) => generate_json_schema::main(&args)?,
+180
View File
@@ -0,0 +1,180 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["coverage"]
# ///
# Adapted from pyca/cryptography's merge_rust_coverage.py
# See: <https://github.com/pyca/cryptography/blob/0bc628d1e/.github/bin/merge_rust_coverage.py>
# License: <https://github.com/pyca/cryptography/blob/0bc628d1e/LICENSE>
from __future__ import annotations
import argparse
import collections
import collections.abc
import pathlib
import webbrowser
import coverage
CoverageData = collections.abc.Mapping[str, collections.abc.Mapping[int, int]]
ID_ALPHABET = frozenset(
"_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
)
# This report uses only source and line records; ignore function, branch, and summary metadata.
IGNORED_RECORDS = {
"BRDA",
"BRF",
"BRH",
"FN",
"FNDA",
"FNF",
"FNH",
"LF",
"LH",
"TN",
}
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
class RustCoveragePlugin(coverage.CoveragePlugin):
def __init__(self, coverage_data: CoverageData) -> None:
super().__init__()
self._data = coverage_data
def file_reporter(self, filename: str) -> coverage.FileReporter:
return RustCoverageFileReporter(filename, self._data[filename])
class RustCoverageFileReporter(coverage.FileReporter):
def __init__(
self, filename: str, coverage_data: collections.abc.Mapping[int, int]
) -> None:
super().__init__(filename)
self._data = coverage_data
def lines(self) -> set[int]:
return set(self._data)
def tracking_id(value: str) -> str:
if not value or not set(value) <= ID_ALPHABET:
raise argparse.ArgumentTypeError(
"coverage ID must be non-empty and use only valid characters"
)
return value
def parse_lcov(path: pathlib.Path) -> CoverageData:
raw_data: collections.defaultdict[str, collections.defaultdict[int, int]] = (
collections.defaultdict(lambda: collections.defaultdict(int))
)
current_file: str | None = None
with path.open(encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
line = line.strip()
if not line:
continue
if line == "end_of_record":
if current_file is None:
raise ValueError(f"{path}:{line_number}: unexpected end_of_record")
current_file = None
continue
prefix, separator, suffix = line.partition(":")
if not separator:
raise ValueError(f"{path}:{line_number}: malformed LCOV record")
if prefix == "SF":
if current_file is not None:
raise ValueError(
f"{path}:{line_number}: source record was not terminated"
)
source = pathlib.Path(suffix)
if not source.is_absolute():
source = REPO_ROOT / source
source = source.resolve()
if not source.is_relative_to(REPO_ROOT):
raise ValueError(
f"{path}:{line_number}: source is outside the repository: {source}"
)
if not source.is_file():
raise ValueError(
f"{path}:{line_number}: source file does not exist: {source}"
)
current_file = str(source)
elif prefix == "DA":
if current_file is None:
raise ValueError(
f"{path}:{line_number}: DA record has no source file"
)
fields = suffix.split(",")
if len(fields) < 2:
raise ValueError(f"{path}:{line_number}: malformed DA record")
try:
source_line = int(fields[0])
count = int(fields[1])
except ValueError as error:
raise ValueError(
f"{path}:{line_number}: malformed DA record"
) from error
raw_data[current_file][source_line] += count
elif prefix not in IGNORED_RECORDS:
raise ValueError(
f"{path}:{line_number}: unsupported LCOV record: {prefix}"
)
if current_file is not None:
raise ValueError(f"{path}: unterminated source record")
if not raw_data:
raise ValueError(f"{path}: no line coverage records found")
return raw_data
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate terminal and HTML reports from a Rust coverage run."
)
parser.add_argument("id", type=tracking_id, help="Coverage tracking ID")
parser.add_argument(
"--open",
action="store_true",
help="Open the generated HTML report in the default browser",
)
args = parser.parse_args()
lcov_path = REPO_ROOT / "target" / "coverage" / "lcov" / f"{args.id}.lcov"
if not lcov_path.is_file():
parser.error(f"LCOV file does not exist: {lcov_path}")
raw_data = parse_lcov(lcov_path)
covered_lines = {
filename: {line for line, count in lines.items() if count > 0}
for filename, lines in raw_data.items()
}
plugin_name = "None.RustCoveragePlugin"
cov = coverage.Coverage(
data_file=None,
config_file=False,
plugins=[
lambda registry: registry.add_file_tracer(RustCoveragePlugin(raw_data))
],
)
data = cov.get_data()
data.add_lines(covered_lines)
data.add_file_tracers(dict.fromkeys(raw_data, plugin_name))
cov.report(show_missing=True)
html_directory = REPO_ROOT / "target" / "coverage" / "html" / args.id
cov.html_report(directory=str(html_directory))
html_report = html_directory / "index.html"
print(f"HTML report: {html_report}")
if args.open:
webbrowser.open(html_report.as_uri())
if __name__ == "__main__":
main()