diff --git a/Cargo.lock b/Cargo.lock index 7432f0ca..a5641a90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1222,10 +1222,12 @@ dependencies = [ "karva_combine", "karva_logging", "karva_macros", + "karva_version", "regex", "ruff_db", "ruff_options_metadata", "ruff_python_ast", + "semver", "serde", "thiserror", "toml", @@ -2292,6 +2294,10 @@ name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +dependencies = [ + "serde", + "serde_core", +] [[package]] name = "serde" diff --git a/Cargo.toml b/Cargo.toml index eba3c1e5..3616790e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/crates/karva/src/commands/test/mod.rs b/crates/karva/src/commands/test/mod.rs index 717b64aa..b1b7f1ba 100644 --- a/crates/karva/src/commands/test/mod.rs +++ b/crates/karva/src/commands/test/mod.rs @@ -37,7 +37,7 @@ pub fn test(args: TestCommand) -> Result { 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)? }; diff --git a/crates/karva/tests/it/configuration/mod.rs b/crates/karva/tests/it/configuration/mod.rs index 9f553a30..3125a756 100644 --- a/crates/karva/tests/it/configuration/mod.rs +++ b/crates/karva/tests/it/configuration/mod.rs @@ -1,4 +1,5 @@ mod profile; +mod required_version; use insta_cmd::assert_cmd_snapshot; diff --git a/crates/karva/tests/it/configuration/profile.rs b/crates/karva/tests/it/configuration/profile.rs index d6e444c5..c8d2a841 100644 --- a/crates/karva/tests/it/configuration/profile.rs +++ b/crates/karva/tests/it/configuration/profile.rs @@ -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` "); } diff --git a/crates/karva/tests/it/configuration/required_version.rs b/crates/karva/tests/it/configuration/required_version.rs new file mode 100644 index 00000000..3304cf9b --- /dev/null +++ b/crates/karva/tests/it/configuration/required_version.rs @@ -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: /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: /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: /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 + "#); +} diff --git a/crates/karva_dev/src/generate_options.rs b/crates/karva_dev/src/generate_options.rs index 17282a3e..92dfc577 100644 --- a/crates/karva_dev/src/generate_options.rs +++ b/crates/karva_dev/src/generate_options.rs @@ -44,6 +44,7 @@ fn generate_set(output: &mut String, set: Set, parents: &mut Vec) { "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 @@ -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); diff --git a/crates/karva_metadata/Cargo.toml b/crates/karva_metadata/Cargo.toml index 24832c36..387332e8 100644 --- a/crates/karva_metadata/Cargo.toml +++ b/crates/karva_metadata/Cargo.toml @@ -16,6 +16,7 @@ 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 } @@ -23,6 +24,7 @@ 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 } diff --git a/crates/karva_metadata/src/lib.rs b/crates/karva_metadata/src/lib.rs index a14fd64b..4034fdcb 100644 --- a/crates/karva_metadata/src/lib.rs +++ b/crates/karva_metadata/src/lib.rs @@ -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::{ @@ -50,19 +51,21 @@ impl ProjectMetadata { } pub fn from_config_file( - path: Utf8PathBuf, + path: &Utf8Path, cwd: &Utf8Path, python_version: PythonVersion, ) -> Result { 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, @@ -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, @@ -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); @@ -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")] @@ -270,4 +287,11 @@ pub enum ProjectMetadataError { source: Box, path: Utf8PathBuf, }, + + #[error("{path}: {source}")] + IncompatibleVersion { + path: Utf8PathBuf, + #[source] + source: IncompatibleVersionError, + }, } diff --git a/crates/karva_metadata/src/options/config.rs b/crates/karva_metadata/src/options/config.rs index 883c7a30..83503cbc 100644 --- a/crates/karva_metadata/src/options/config.rs +++ b/crates/karva_metadata/src/options/config.rs @@ -1,7 +1,8 @@ use std::collections::BTreeMap; -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use karva_combine::Combine; +use semver::{Version, VersionReq}; use serde::{Deserialize, Serialize}; use thiserror::Error; @@ -18,6 +19,15 @@ pub const DEFAULT_PROFILE: &str = "default"; #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case", deny_unknown_fields)] pub struct Config { + /// `SemVer` requirement that the running karva binary must satisfy. + /// + /// When set, karva refuses to run if the installed version does not + /// match the requirement. This is useful in CI and for shared + /// repositories where every developer should be on a known-good + /// version. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub required_version: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub profile: BTreeMap, } @@ -29,13 +39,38 @@ impl Config { Ok(config) } - pub(crate) fn from_karva_configuration_file( - path: &Utf8PathBuf, - ) -> Result { + /// Verify that the running karva version satisfies `required-version`. + /// + /// `current` is parsed once with [`semver::Version::parse`]; karva's + /// own version is well-formed semver, so a parse failure here is an + /// internal error rather than a configuration problem. + pub fn check_required_version(&self, current: &str) -> Result<(), IncompatibleVersionError> { + let Some(required) = &self.required_version else { + return Ok(()); + }; + + let installed = Version::parse(current).map_err(|source| { + IncompatibleVersionError::InvalidInstalledVersion { + version: current.to_string(), + source, + } + })?; + + if required.matches(&installed) { + Ok(()) + } else { + Err(IncompatibleVersionError::Mismatch { + required: required.clone(), + installed, + }) + } + } + + pub(crate) fn from_karva_configuration_file(path: &Utf8Path) -> Result { let karva_toml_str = std::fs::read_to_string(path).map_err(|source| KarvaTomlError::FileReadError { source, - path: path.clone(), + path: path.to_path_buf(), })?; Self::from_toml_str(&karva_toml_str) @@ -126,6 +161,21 @@ pub struct UnknownProfile { pub available: Vec, } +#[derive(Debug, Error)] +pub enum IncompatibleVersionError { + #[error("the installed karva {installed} does not satisfy `required-version = \"{required}\"`")] + Mismatch { + required: VersionReq, + installed: Version, + }, + #[error("internal error: failed to parse installed karva {version}: {source}")] + InvalidInstalledVersion { + version: String, + #[source] + source: semver::Error, + }, +} + #[derive(Error, Debug)] pub enum KarvaTomlError { #[error(transparent)] @@ -139,3 +189,50 @@ pub enum KarvaTomlError { #[error("invalid profile name `{name}`: {reason}")] InvalidProfileName { name: String, reason: &'static str }, } + +#[cfg(test)] +mod tests { + use insta::assert_snapshot; + + use super::*; + + #[test] + fn required_version_satisfied() { + let config = + Config::from_toml_str(r#"required-version = ">=0.0.1-alpha.1""#).expect("parse"); + config.check_required_version("0.0.1-alpha.5").expect("ok"); + } + + #[test] + fn required_version_unsatisfied_reports_both_versions() { + let config = Config::from_toml_str(r#"required-version = ">=1.0.0""#).expect("parse"); + let err = config + .check_required_version("0.5.2") + .expect_err("mismatch"); + assert_snapshot!( + err, + @r#"the installed karva 0.5.2 does not satisfy `required-version = ">=1.0.0"`"# + ); + } + + #[test] + fn required_version_absent_is_noop() { + Config::default() + .check_required_version("0.0.0") + .expect("ok"); + } + + #[test] + fn invalid_required_version_is_a_parse_error() { + let err = + Config::from_toml_str(r#"required-version = "not a version""#).expect_err("invalid"); + assert_snapshot!(err, @r#" + TOML parse error at line 1, column 20 + | + 1 | required-version = "not a version" + | ^^^^^^^^^^^^^^^ + unexpected character 'n' while parsing major version number + + "#); + } +} diff --git a/crates/karva_metadata/src/options/mod.rs b/crates/karva_metadata/src/options/mod.rs index 0e27f4df..2e8070b2 100644 --- a/crates/karva_metadata/src/options/mod.rs +++ b/crates/karva_metadata/src/options/mod.rs @@ -7,7 +7,9 @@ use karva_macros::{Combine, OptionsMetadata}; use ruff_db::diagnostic::DiagnosticFormat; use serde::{Deserialize, Serialize}; -pub use config::{Config, DEFAULT_PROFILE, KarvaTomlError, UnknownProfile}; +pub use config::{ + Config, DEFAULT_PROFILE, IncompatibleVersionError, KarvaTomlError, UnknownProfile, +}; pub use overrides::ProjectOptionsOverrides; use crate::filter::FiltersetSet; @@ -551,7 +553,7 @@ foo = 1 | 2 | [bogus] | ^^^^^ - unknown field `bogus`, expected `profile` + unknown field `bogus`, expected `required-version` or `profile` " ); } @@ -569,7 +571,7 @@ test-function-prefix = "test" | 2 | [test] | ^^^^ - unknown field `test`, expected `profile` + unknown field `test`, expected `required-version` or `profile` " ); } @@ -578,6 +580,7 @@ test-function-prefix = "test" fn from_toml_str_empty_is_default() { assert_debug_snapshot!(Config::from_toml_str("").expect("parse"), @" Config { + required_version: None, profile: {}, } "); diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index 5b37aa7c..9fca6687 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -6,6 +6,33 @@ Karva is configured through `karva.toml` (or the `[tool.karva]` table in `pyproj The reference below documents every field supported inside a profile. Examples target the implicit `default` profile. +## `required-version` + +A SemVer requirement that the running karva binary must satisfy. + +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. + +`required-version` is a top-level field, not part of any profile. + +**Default value**: `null` + +**Type**: SemVer requirement (`string`) + +**Example usage** (`karva.toml`): + +```toml +required-version = ">=0.5.0" +``` + +The same field in `pyproject.toml` lives under `[tool.karva]`: + +```toml +[tool.karva] +required-version = ">=0.5.0" +``` + +--- + ## `coverage` ### `fail-under`