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
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ ruff_python_parser = { git = "https://github.com/astral-sh/ruff/", branch = "mai
ruff_python_trivia = { git = "https://github.com/astral-sh/ruff/", branch = "main" }
ruff_source_file = { git = "https://github.com/astral-sh/ruff/", branch = "main" }
ruff_text_size = { git = "https://github.com/astral-sh/ruff/", branch = "main" }
semver = { version = "1.0.27", features = ["serde"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = { version = "1.0.149" }
similar = { version = "3.0", features = ["inline"] }
Expand Down
2 changes: 1 addition & 1 deletion crates/karva/src/commands/test/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ pub fn test(args: TestCommand) -> Result<ExitStatus> {
let config_file = args.config_file.as_ref().map(|path| absolute(path, &cwd));

let mut project_metadata = if let Some(config_file) = &config_file {
ProjectMetadata::from_config_file(config_file.clone(), &cwd, python_version)?
ProjectMetadata::from_config_file(config_file, &cwd, python_version)?
} else {
ProjectMetadata::discover(&cwd, python_version)?
};
Expand Down
1 change: 1 addition & 0 deletions crates/karva/tests/it/configuration/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod profile;
mod required_version;

use insta_cmd::assert_cmd_snapshot;

Expand Down
4 changes: 2 additions & 2 deletions crates/karva/tests/it/configuration/profile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,13 +330,13 @@ test-function-prefix = "check"
|
2 | [test]
| ^^^^
unknown field `test`, expected `profile`
unknown field `test`, expected `required-version` or `profile`

Cause: TOML parse error at line 2, column 2
|
2 | [test]
| ^^^^
unknown field `test`, expected `profile`
unknown field `test`, expected `required-version` or `profile`
");
}

Expand Down
113 changes: 113 additions & 0 deletions crates/karva/tests/it/configuration/required_version.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
use insta_cmd::assert_cmd_snapshot;

use crate::common::TestContext;

#[test]
fn required_version_satisfied_in_karva_toml() {
let context = TestContext::with_files([
(
"karva.toml",
r#"
required-version = ">=0.0.1-alpha.1"
"#,
),
("test.py", "def test_pass(): pass\n"),
]);

assert_cmd_snapshot!(context.command(), @r"
success: true
exit_code: 0
----- stdout -----
Starting 1 test across 1 worker
PASS [TIME] test::test_pass
────────────
Summary [TIME] 1 test run: 1 passed, 0 skipped

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

#[test]
fn required_version_unsatisfied_in_karva_toml_fails_before_running() {
let context = TestContext::with_files([
(
"karva.toml",
r#"
required-version = ">=999.0.0"
"#,
),
("test.py", "def test_pass(): pass\n"),
]);

assert_cmd_snapshot!(context.command(), @r#"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
Karva failed
Cause: <temp_dir>/karva.toml: the installed karva [VERSION] does not satisfy `required-version = ">=999.0.0"`
Cause: the installed karva [VERSION] does not satisfy `required-version = ">=999.0.0"`
"#);
}

#[test]
fn required_version_unsatisfied_in_pyproject_toml_fails() {
let context = TestContext::with_files([
(
"pyproject.toml",
r#"
[project]
name = "test-project"

[tool.karva]
required-version = ">=999.0.0"
"#,
),
("test.py", "def test_pass(): pass\n"),
]);

assert_cmd_snapshot!(context.command(), @r#"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
Karva failed
Cause: <temp_dir>/pyproject.toml: the installed karva [VERSION] does not satisfy `required-version = ">=999.0.0"`
Cause: the installed karva [VERSION] does not satisfy `required-version = ">=999.0.0"`
"#);
}

#[test]
fn required_version_invalid_specifier_is_a_parse_error() {
let context = TestContext::with_files([
(
"karva.toml",
r#"
required-version = "not a version"
"#,
),
("test.py", "def test_pass(): pass\n"),
]);

assert_cmd_snapshot!(context.command(), @r#"
success: false
exit_code: 2
----- stdout -----

----- stderr -----
Karva failed
Cause: <temp_dir>/karva.toml is not a valid `karva.toml`: TOML parse error at line 2, column 20
|
2 | required-version = "not a version"
| ^^^^^^^^^^^^^^^
unexpected character 'n' while parsing major version number

Cause: TOML parse error at line 2, column 20
|
2 | required-version = "not a version"
| ^^^^^^^^^^^^^^^
unexpected character 'n' while parsing major version number
"#);
}
23 changes: 23 additions & 0 deletions crates/karva_dev/src/generate_options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ fn generate_set(output: &mut String, set: Set, parents: &mut Vec<Set>) {
"The reference below documents every field supported inside a profile. Examples \
target the implicit `default` profile.\n\n",
);
emit_required_version_section(output);
}
Set::Named { name, .. } => {
let title = parents
Expand Down Expand Up @@ -114,6 +115,28 @@ impl Set {
}
}

/// `required-version` lives at the root of `Config`, not inside any profile,
/// so the metadata-driven walker does not pick it up. Emit a hand-rolled
/// section for it just below the intro paragraph instead.
fn emit_required_version_section(output: &mut String) {
output.push_str("## `required-version`\n\n");
output.push_str(
"A SemVer requirement that the running karva binary must satisfy.\n\n\
If the installed karva version does not match the requirement, karva exits with a \
clear error before running any tests. This prevents confusing failures when CI or \
other contributors run with an older version that does not support features used \
elsewhere in the configuration.\n\n\
`required-version` is a top-level field, not part of any profile.\n\n",
);
output.push_str("**Default value**: `null`\n\n");
output.push_str("**Type**: SemVer requirement (`string`)\n\n");
output.push_str("**Example usage** (`karva.toml`):\n\n");
output.push_str("```toml\nrequired-version = \">=0.5.0\"\n```\n\n");
output.push_str("The same field in `pyproject.toml` lives under `[tool.karva]`:\n\n");
output.push_str("```toml\n[tool.karva]\nrequired-version = \">=0.5.0\"\n```\n\n");
output.push_str("---\n\n");
}

fn emit_field(output: &mut String, name: &str, field: &OptionField, parents: &[Set]) {
let header_level = "#".repeat(parents.len() + 1);

Expand Down
2 changes: 2 additions & 0 deletions crates/karva_metadata/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,15 @@ ignored = ["ruff_options_metadata"]
karva_combine = { workspace = true }
karva_logging = { workspace = true }
karva_macros = { workspace = true }
karva_version = { workspace = true }

camino = { workspace = true }
globset = { workspace = true }
regex = { workspace = true }
ruff_db = { workspace = true }
ruff_options_metadata = { workspace = true }
ruff_python_ast = { workspace = true }
semver = { workspace = true }
serde = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
Expand Down
34 changes: 29 additions & 5 deletions crates/karva_metadata/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ mod settings;

pub use max_fail::MaxFail;
pub use options::{
Config, CovReport, CoverageOptions, DEFAULT_PROFILE, Options, OutputFormat,
ProjectOptionsOverrides, SrcOptions, TerminalOptions, TestOptions, UnknownProfile,
Config, CovReport, CoverageOptions, DEFAULT_PROFILE, IncompatibleVersionError, Options,
OutputFormat, ProjectOptionsOverrides, SrcOptions, TerminalOptions, TestOptions,
UnknownProfile,
};
pub use pyproject::{PyProject, PyProjectError};
pub use settings::{
Expand Down Expand Up @@ -50,19 +51,21 @@ impl ProjectMetadata {
}

pub fn from_config_file(
path: Utf8PathBuf,
path: &Utf8Path,
cwd: &Utf8Path,
python_version: PythonVersion,
) -> Result<Self, ProjectMetadataError> {
tracing::debug!("Using overridden configuration file at '{path}'");

let config = Config::from_karva_configuration_file(&path).map_err(|error| {
let config = Config::from_karva_configuration_file(path).map_err(|error| {
ProjectMetadataError::InvalidKarvaToml {
source: Box::new(error),
path,
path: path.to_path_buf(),
}
})?;

check_required_version(&config, path)?;

Ok(Self {
root: cwd.to_path_buf(),
python_version,
Expand Down Expand Up @@ -134,6 +137,8 @@ impl ProjectMetadata {
);
}

check_required_version(&config, &project_root.join("karva.toml"))?;

tracing::debug!("Found project at '{}'", project_root);
return Ok(Self::from_config(
config,
Expand All @@ -144,6 +149,9 @@ impl ProjectMetadata {

if let Some(pyproject) = pyproject {
let has_karva = pyproject.karva().is_some();
if let Some(config) = pyproject.karva() {
check_required_version(config, &project_root.join("pyproject.toml"))?;
}
let metadata =
Self::from_pyproject(pyproject, project_root.to_path_buf(), python_version);

Expand Down Expand Up @@ -254,6 +262,15 @@ fn has_karva_section(pyproject: Option<&PyProject>) -> bool {
pyproject.is_some_and(|project| project.karva().is_some())
}

fn check_required_version(config: &Config, path: &Utf8Path) -> Result<(), ProjectMetadataError> {
config
.check_required_version(karva_version::version())
.map_err(|source| ProjectMetadataError::IncompatibleVersion {
path: path.to_path_buf(),
source,
})
}

#[derive(Debug, Error)]
pub enum ProjectMetadataError {
#[error("project path '{0}' is not a directory")]
Expand All @@ -270,4 +287,11 @@ pub enum ProjectMetadataError {
source: Box<KarvaTomlError>,
path: Utf8PathBuf,
},

#[error("{path}: {source}")]
IncompatibleVersion {
path: Utf8PathBuf,
#[source]
source: IncompatibleVersionError,
},
}
Loading
Loading