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
2 changes: 2 additions & 0 deletions crates/karva/src/commands/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ pub fn test(args: TestCommand) -> Result<ExitStatus> {
let watch = args.watch;
let durations = args.durations;
let last_failed = args.last_failed;
let partition = args.partition;
let no_cache = args.no_cache.unwrap_or(false);
let num_workers = if args.no_parallel.unwrap_or(false) || args.no_capture {
1
Expand Down Expand Up @@ -76,6 +77,7 @@ pub fn test(args: TestCommand) -> Result<ExitStatus> {
create_ctrlc_handler: true,
last_failed,
profile,
partition,
};

if watch {
Expand Down
1 change: 1 addition & 0 deletions crates/karva/tests/it/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ mod durations;
mod extensions;
mod filterset;
mod last_failed;
mod partition;
mod run_ignored;
mod version;
mod watch;
138 changes: 138 additions & 0 deletions crates/karva/tests/it/partition.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
use insta_cmd::assert_cmd_snapshot;

use crate::common::TestContext;

const SIX_TESTS: &str = "
def test_a(): pass
def test_b(): pass
def test_c(): pass
def test_d(): pass
def test_e(): pass
def test_f(): pass
";

#[test]
fn slice_first_of_three() {
let context = TestContext::with_file("test_mod.py", SIX_TESTS);

assert_cmd_snapshot!(
context.command_no_parallel().arg("--partition=slice:1/3"),
@r"
success: true
exit_code: 0
----- stdout -----
Starting 6 tests across 1 worker
PASS [TIME] test_mod::test_a
PASS [TIME] test_mod::test_d
────────────
Summary [TIME] 2 tests run: 2 passed, 0 skipped

----- stderr -----
"
);
}

#[test]
fn slice_second_of_three() {
let context = TestContext::with_file("test_mod.py", SIX_TESTS);

assert_cmd_snapshot!(
context.command_no_parallel().arg("--partition=slice:2/3"),
@r"
success: true
exit_code: 0
----- stdout -----
Starting 6 tests across 1 worker
PASS [TIME] test_mod::test_b
PASS [TIME] test_mod::test_e
────────────
Summary [TIME] 2 tests run: 2 passed, 0 skipped

----- stderr -----
"
);
}

#[test]
fn slice_third_of_three() {
let context = TestContext::with_file("test_mod.py", SIX_TESTS);

assert_cmd_snapshot!(
context.command_no_parallel().arg("--partition=slice:3/3"),
@r"
success: true
exit_code: 0
----- stdout -----
Starting 6 tests across 1 worker
PASS [TIME] test_mod::test_c
PASS [TIME] test_mod::test_f
────────────
Summary [TIME] 2 tests run: 2 passed, 0 skipped

----- stderr -----
"
);
}

#[test]
fn slice_one_of_one_runs_everything() {
let context = TestContext::with_file("test_mod.py", SIX_TESTS);

assert_cmd_snapshot!(
context.command_no_parallel().arg("--partition=slice:1/1"),
@r"
success: true
exit_code: 0
----- stdout -----
Starting 6 tests across 1 worker
PASS [TIME] test_mod::test_a
PASS [TIME] test_mod::test_b
PASS [TIME] test_mod::test_c
PASS [TIME] test_mod::test_d
PASS [TIME] test_mod::test_e
PASS [TIME] test_mod::test_f
────────────
Summary [TIME] 6 tests run: 6 passed, 0 skipped

----- stderr -----
"
);
}

#[test]
fn invalid_partition_index_above_total_errors() {
let context = TestContext::with_file("test_mod.py", SIX_TESTS);

assert_cmd_snapshot!(
context.command_no_parallel().arg("--partition=slice:4/3"),
@r"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
error: invalid value 'slice:4/3' for '--partition <STRATEGY:M/N>': partition index `M` (4) must not exceed partition count `N` (3)

For more information, try '--help'.
"
);
}

#[test]
fn invalid_partition_strategy_errors() {
let context = TestContext::with_file("test_mod.py", SIX_TESTS);

assert_cmd_snapshot!(
context.command_no_parallel().arg("--partition=hash:1/3"),
@r"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
error: invalid value 'hash:1/3' for '--partition <STRATEGY:M/N>': unknown partition strategy `hash`; supported strategies: `slice`

For more information, try '--help'.
"
);
}
1 change: 1 addition & 0 deletions crates/karva_benchmark/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ pub fn run_karva(project: &Project) {
create_ctrlc_handler: false,
last_failed: false,
profile: None,
partition: None,
};

let args = SubTestCommand {
Expand Down
2 changes: 2 additions & 0 deletions crates/karva_cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ use clap::builder::styling::{AnsiColor, Effects};

mod cache;
mod enums;
mod partition;
mod snapshot;
mod test;
mod verbosity;

pub use cache::{CacheAction, CacheCommand};
pub use enums::{CovReport, NoTests, OutputFormat, RunIgnored};
pub use partition::PartitionSelection;
pub use snapshot::{
SnapshotAction, SnapshotCommand, SnapshotDeleteArgs, SnapshotFilterArgs, SnapshotPruneArgs,
};
Expand Down
142 changes: 142 additions & 0 deletions crates/karva_cli/src/partition.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
use std::fmt;
use std::str::FromStr;

/// Selection of a single partition (slice) from the collected tests.
///
/// Used by `--partition slice:M/N` to run only the tests assigned to slice
/// `M` of `N`. Slice indices are 1-indexed: `slice:1/3`, `slice:2/3`,
/// `slice:3/3` together cover every collected test exactly once.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PartitionSelection {
pub index: u32,
pub total: u32,
}

impl PartitionSelection {
/// Returns true if the test at `position` (0-indexed, in the deterministic
/// post-filter ordering) belongs to this slice.
#[must_use]
pub fn contains(self, position: usize) -> bool {
// 1-indexed input -> 0-indexed modulo target.
let target = (self.index - 1) as usize;
let total = self.total as usize;
position % total == target
}
}

impl fmt::Display for PartitionSelection {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "slice:{}/{}", self.index, self.total)
}
}

impl FromStr for PartitionSelection {
type Err = String;

fn from_str(raw: &str) -> Result<Self, Self::Err> {
let (kind, body) = raw.split_once(':').ok_or_else(|| {
format!("expected `<strategy>:<M>/<N>` (e.g. `slice:1/3`), got `{raw}`")
})?;

if kind != "slice" {
return Err(format!(
"unknown partition strategy `{kind}`; supported strategies: `slice`"
));
}

let (m, n) = body
.split_once('/')
.ok_or_else(|| format!("expected `slice:<M>/<N>`, got `slice:{body}`"))?;

let index: u32 = m
.parse()
.map_err(|err| format!("`{m}` is not a valid partition index: {err}"))?;
let total: u32 = n
.parse()
.map_err(|err| format!("`{n}` is not a valid partition count: {err}"))?;

if total == 0 {
return Err("partition count `N` must be at least 1".to_string());
}
if index == 0 {
return Err("partition index `M` must be at least 1".to_string());
}
if index > total {
return Err(format!(
"partition index `M` ({index}) must not exceed partition count `N` ({total})"
));
}

Ok(Self { index, total })
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parses_valid_slice() {
assert_eq!(
"slice:1/3".parse::<PartitionSelection>().unwrap(),
PartitionSelection { index: 1, total: 3 },
);
assert_eq!(
"slice:3/3".parse::<PartitionSelection>().unwrap(),
PartitionSelection { index: 3, total: 3 },
);
assert_eq!(
"slice:1/1".parse::<PartitionSelection>().unwrap(),
PartitionSelection { index: 1, total: 1 },
);
}

#[test]
fn rejects_zero_total() {
assert!("slice:1/0".parse::<PartitionSelection>().is_err());
}

#[test]
fn rejects_zero_index() {
assert!("slice:0/3".parse::<PartitionSelection>().is_err());
}

#[test]
fn rejects_index_above_total() {
assert!("slice:4/3".parse::<PartitionSelection>().is_err());
}

#[test]
fn rejects_unknown_strategy() {
assert!("hash:1/3".parse::<PartitionSelection>().is_err());
}

#[test]
fn rejects_missing_separators() {
assert!("slice".parse::<PartitionSelection>().is_err());
assert!("slice:13".parse::<PartitionSelection>().is_err());
assert!("1/3".parse::<PartitionSelection>().is_err());
}

#[test]
fn contains_round_robin() {
let p = PartitionSelection { index: 1, total: 3 };
assert!(p.contains(0));
assert!(!p.contains(1));
assert!(!p.contains(2));
assert!(p.contains(3));

let q = PartitionSelection { index: 3, total: 3 };
assert!(!q.contains(0));
assert!(!q.contains(1));
assert!(q.contains(2));
assert!(q.contains(5));
}

#[test]
fn display_round_trip() {
let p = PartitionSelection { index: 2, total: 5 };
assert_eq!(p.to_string(), "slice:2/5");
assert_eq!(p.to_string().parse::<PartitionSelection>().unwrap(), p);
}
}
16 changes: 16 additions & 0 deletions crates/karva_cli/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use karva_metadata::{
};

use crate::enums::{CovReport, NoTests, OutputFormat, RunIgnored};
use crate::partition::PartitionSelection;
use crate::verbosity::Verbosity;

/// Shared test execution options that can be used by both main CLI and worker processes
Expand Down Expand Up @@ -219,6 +220,21 @@ pub struct TestCommand {
#[clap(long, alias = "lf", help_heading = "Filter options")]
pub last_failed: bool,

/// Run only a slice of the collected tests, distributed round-robin.
///
/// Accepts `slice:M/N` where this run executes slice `M` of `N` total
/// slices (1-indexed). Tests are sorted by qualified name and then
/// distributed by cycling through slices: test 1 to slice 1, test 2 to
/// slice 2, ..., test N+1 to slice 1, and so on. Running every
/// `slice:1/N` through `slice:N/N` together covers every collected test
/// exactly once.
///
/// Useful for splitting a test run across CI jobs. Slice membership
/// shifts when tests are added or removed, so it gives less stable
/// per-test placement than a hash-based scheme.
#[clap(long, value_name = "STRATEGY:M/N", help_heading = "Filter options")]
pub partition: Option<PartitionSelection>,

/// Number of parallel workers (default: number of CPU cores)
#[clap(short = 'n', long, help_heading = "Runner options")]
pub num_workers: Option<usize>,
Expand Down
5 changes: 4 additions & 1 deletion crates/karva_runner/src/orchestration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use karva_cache::{
AggregatedResults, CACHE_DIR, RunCache, RunHash, read_last_failed, read_recent_durations,
write_last_failed,
};
use karva_cli::SubTestCommand;
use karva_cli::{PartitionSelection, SubTestCommand};
use karva_collector::{CollectedPackage, CollectionSettings};
use karva_logging::Printer;
use karva_logging::time::format_duration;
Expand Down Expand Up @@ -154,6 +154,8 @@ pub struct ParallelTestConfig {
/// Active configuration profile name. Propagated to workers as
/// `KARVA_PROFILE`; falls back to `"default"` when `None`.
pub profile: Option<String>,
/// When set, restrict the run to the selected slice of collected tests.
pub partition: Option<PartitionSelection>,
}

/// Spawn worker processes for each partition
Expand Down Expand Up @@ -303,6 +305,7 @@ pub fn run_parallel_tests(
num_workers,
&previous_durations,
&last_failed_set,
config.partition,
);

let run_hash = RunHash::current_time();
Expand Down
Loading
Loading