Accept a PEP 440 version specifier for required-version (#10216)

## Summary

Allows `required-version` to be set with a version specifier, like
`>=0.3.1`.

If a single version is provided, falls back to assuming `==0.3.1`, for
backwards compatibility.

Closes https://github.com/astral-sh/ruff/issues/10192.
This commit is contained in:
Charlie Marsh 2024-03-03 15:43:49 -08:00 committed by GitHub
parent db25a563f7
commit 84bf333031
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 217 additions and 39 deletions

7
Cargo.lock generated
View File

@ -2158,7 +2158,6 @@ dependencies = [
"ruff_text_size",
"rustc-hash",
"schemars",
"semver",
"serde",
"serde_json",
"similar",
@ -2581,12 +2580,6 @@ version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b"
[[package]]
name = "semver"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca"
[[package]]
name = "serde"
version = "1.0.197"

View File

@ -76,7 +76,6 @@ result-like = { version = "0.5.0" }
rustc-hash = { version = "1.1.0" }
schemars = { version = "0.8.16" }
seahash = { version = "4.1.0" }
semver = { version = "1.0.22" }
serde = { version = "1.0.197", features = ["derive"] }
serde-wasm-bindgen = { version = "0.6.4" }
serde_json = { version = "1.0.113" }

View File

@ -972,3 +972,157 @@ import os
Ok(())
}
#[test]
fn required_version_exact_mismatch() -> Result<()> {
let version = env!("CARGO_PKG_VERSION");
let tempdir = TempDir::new()?;
let ruff_toml = tempdir.path().join("ruff.toml");
fs::write(
&ruff_toml,
r#"
required-version = "0.1.0"
"#,
)?;
insta::with_settings!({
filters => vec![(tempdir_filter(&tempdir).as_str(), "[TMP]/"), (version, "[VERSION]")]
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.arg("--config")
.arg(&ruff_toml)
.arg("-")
.pass_stdin(r#"
import os
"#), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
ruff failed
Cause: Required version `==0.1.0` does not match the running version `[VERSION]`
"###);
});
Ok(())
}
#[test]
fn required_version_exact_match() -> Result<()> {
let version = env!("CARGO_PKG_VERSION");
let tempdir = TempDir::new()?;
let ruff_toml = tempdir.path().join("ruff.toml");
fs::write(
&ruff_toml,
format!(
r#"
required-version = "{version}"
"#
),
)?;
insta::with_settings!({
filters => vec![(tempdir_filter(&tempdir).as_str(), "[TMP]/"), (version, "[VERSION]")]
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.arg("--config")
.arg(&ruff_toml)
.arg("-")
.pass_stdin(r#"
import os
"#), @r###"
success: false
exit_code: 1
----- stdout -----
-:2:8: F401 [*] `os` imported but unused
Found 1 error.
[*] 1 fixable with the `--fix` option.
----- stderr -----
"###);
});
Ok(())
}
#[test]
fn required_version_bound_mismatch() -> Result<()> {
let version = env!("CARGO_PKG_VERSION");
let tempdir = TempDir::new()?;
let ruff_toml = tempdir.path().join("ruff.toml");
fs::write(
&ruff_toml,
format!(
r#"
required-version = ">{version}"
"#
),
)?;
insta::with_settings!({
filters => vec![(tempdir_filter(&tempdir).as_str(), "[TMP]/"), (version, "[VERSION]")]
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.arg("--config")
.arg(&ruff_toml)
.arg("-")
.pass_stdin(r#"
import os
"#), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
ruff failed
Cause: Required version `>[VERSION]` does not match the running version `[VERSION]`
"###);
});
Ok(())
}
#[test]
fn required_version_bound_match() -> Result<()> {
let version = env!("CARGO_PKG_VERSION");
let tempdir = TempDir::new()?;
let ruff_toml = tempdir.path().join("ruff.toml");
fs::write(
&ruff_toml,
r#"
required-version = ">=0.1.0"
"#,
)?;
insta::with_settings!({
filters => vec![(tempdir_filter(&tempdir).as_str(), "[TMP]/"), (version, "[VERSION]")]
}, {
assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME))
.args(STDIN_BASE_OPTIONS)
.arg("--config")
.arg(&ruff_toml)
.arg("-")
.pass_stdin(r#"
import os
"#), @r###"
success: false
exit_code: 1
----- stdout -----
-:2:8: F401 [*] `os` imported but unused
Found 1 error.
[*] 1 fixable with the `--fix` option.
----- stderr -----
"###);
});
Ok(())
}

View File

@ -60,7 +60,6 @@ regex = { workspace = true }
result-like = { workspace = true }
rustc-hash = { workspace = true }
schemars = { workspace = true, optional = true }
semver = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
similar = { workspace = true }

View File

@ -7,7 +7,7 @@ use std::string::ToString;
use anyhow::{bail, Result};
use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder};
use pep440_rs::{Version as Pep440Version, VersionSpecifiers};
use pep440_rs::{Version as Pep440Version, VersionSpecifier, VersionSpecifiers};
use rustc_hash::FxHashMap;
use serde::{de, Deserialize, Deserializer, Serialize};
use strum::IntoEnumIterator;
@ -536,22 +536,44 @@ impl SerializationFormat {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Hash)]
#[serde(try_from = "String")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Version(String);
pub struct RequiredVersion(VersionSpecifiers);
impl TryFrom<String> for Version {
type Error = semver::Error;
impl TryFrom<String> for RequiredVersion {
type Error = pep440_rs::Pep440Error;
fn try_from(value: String) -> Result<Self, Self::Error> {
semver::Version::parse(&value).map(|_| Self(value))
// Treat `0.3.1` as `==0.3.1`, for backwards compatibility.
if let Ok(version) = pep440_rs::Version::from_str(&value) {
Ok(Self(VersionSpecifiers::from(
VersionSpecifier::equals_version(version),
)))
} else {
Ok(Self(VersionSpecifiers::from_str(&value)?))
}
}
}
impl Deref for Version {
type Target = str;
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for RequiredVersion {
fn schema_name() -> String {
"RequiredVersion".to_string()
}
fn deref(&self) -> &Self::Target {
&self.0
fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema {
gen.subschema_for::<String>()
}
}
impl RequiredVersion {
/// Return `true` if the given version is required.
pub fn contains(&self, version: &pep440_rs::Version) -> bool {
self.0.contains(version)
}
}
impl Display for RequiredVersion {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
Display::fmt(&self.0, f)
}
}

View File

@ -6,12 +6,12 @@ use std::borrow::Cow;
use std::env::VarError;
use std::num::{NonZeroU16, NonZeroU8};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use anyhow::{anyhow, Result};
use glob::{glob, GlobError, Paths, PatternError};
use itertools::Itertools;
use regex::Regex;
use ruff_linter::settings::fix_safety_table::FixSafetyTable;
use rustc_hash::{FxHashMap, FxHashSet};
use shellexpand;
use shellexpand::LookupError;
@ -24,10 +24,11 @@ use ruff_linter::registry::RuleNamespace;
use ruff_linter::registry::{Rule, RuleSet, INCOMPATIBLE_CODES};
use ruff_linter::rule_selector::{PreviewOptions, Specificity};
use ruff_linter::rules::pycodestyle;
use ruff_linter::settings::fix_safety_table::FixSafetyTable;
use ruff_linter::settings::rule_table::RuleTable;
use ruff_linter::settings::types::{
ExtensionMapping, FilePattern, FilePatternSet, PerFileIgnore, PerFileIgnores, PreviewMode,
PythonVersion, SerializationFormat, UnsafeFixes, Version,
PythonVersion, RequiredVersion, SerializationFormat, UnsafeFixes,
};
use ruff_linter::settings::{LinterSettings, DEFAULT_SELECTORS, DUMMY_VARIABLE_RGX, TASK_TAGS};
use ruff_linter::{
@ -116,7 +117,7 @@ pub struct Configuration {
pub unsafe_fixes: Option<UnsafeFixes>,
pub output_format: Option<SerializationFormat>,
pub preview: Option<PreviewMode>,
pub required_version: Option<Version>,
pub required_version: Option<RequiredVersion>,
pub extension: Option<ExtensionMapping>,
pub show_fixes: Option<bool>,
@ -145,10 +146,12 @@ pub struct Configuration {
impl Configuration {
pub fn into_settings(self, project_root: &Path) -> Result<Settings> {
if let Some(required_version) = &self.required_version {
if &**required_version != RUFF_PKG_VERSION {
let ruff_pkg_version = pep440_rs::Version::from_str(RUFF_PKG_VERSION)
.expect("RUFF_PKG_VERSION is not a valid PEP 440 version specifier");
if !required_version.contains(&ruff_pkg_version) {
return Err(anyhow!(
"Required version `{}` does not match the running version `{}`",
&**required_version,
required_version,
RUFF_PKG_VERSION
));
}
@ -1467,15 +1470,18 @@ fn warn_about_deprecated_top_level_lint_options(
#[cfg(test)]
mod tests {
use crate::configuration::{LintConfiguration, RuleSelection};
use crate::options::PydocstyleOptions;
use std::str::FromStr;
use anyhow::Result;
use ruff_linter::codes::{Flake8Copyright, Pycodestyle, Refurb};
use ruff_linter::registry::{Linter, Rule, RuleSet};
use ruff_linter::rule_selector::PreviewOptions;
use ruff_linter::settings::types::PreviewMode;
use ruff_linter::RuleSelector;
use std::str::FromStr;
use crate::configuration::{LintConfiguration, RuleSelection};
use crate::options::PydocstyleOptions;
const PREVIEW_RULES: &[Rule] = &[
Rule::IsinstanceTypeNone,

View File

@ -6,7 +6,6 @@ use rustc_hash::{FxHashMap, FxHashSet};
use serde::{Deserialize, Serialize};
use strum::IntoEnumIterator;
use crate::options_base::{OptionsMetadata, Visit};
use ruff_formatter::IndentStyle;
use ruff_linter::line_width::{IndentWidth, LineLength};
use ruff_linter::rules::flake8_pytest_style::settings::SettingsError;
@ -25,12 +24,13 @@ use ruff_linter::rules::{
pycodestyle, pydocstyle, pyflakes, pylint, pyupgrade,
};
use ruff_linter::settings::types::{
IdentifierPattern, PythonVersion, SerializationFormat, Version,
IdentifierPattern, PythonVersion, RequiredVersion, SerializationFormat,
};
use ruff_linter::{warn_user_once, RuleSelector};
use ruff_macros::{CombineOptions, OptionsMetadata};
use ruff_python_formatter::{DocstringCodeLineWidth, QuoteStyle};
use crate::options_base::{OptionsMetadata, Visit};
use crate::settings::LineEnding;
#[derive(Clone, Debug, PartialEq, Eq, Default, OptionsMetadata, Serialize, Deserialize)]
@ -135,17 +135,22 @@ pub struct Options {
)]
pub show_fixes: Option<bool>,
/// Require a specific version of Ruff to be running (useful for unifying
/// results across many environments, e.g., with a `pyproject.toml`
/// file).
/// Enforce a requirement on the version of Ruff, to enforce at runtime.
/// If the version of Ruff does not meet the requirement, Ruff will exit
/// with an error.
///
/// Useful for unifying results across many environments, e.g., with a
/// `pyproject.toml` file.
///
/// Accepts a PEP 440 specifier, like `==0.3.1` or `>=0.3.1`.
#[option(
default = "null",
value_type = "str",
example = r#"
required-version = "0.0.193"
required-version = ">=0.0.193"
"#
)]
pub required_version: Option<Version>,
pub required_version: Option<RequiredVersion>,
/// Whether to enable preview mode. When preview mode is enabled, Ruff will
/// use unstable rules, fixes, and formatting.

10
ruff.schema.json generated
View File

@ -623,10 +623,10 @@
]
},
"required-version": {
"description": "Require a specific version of Ruff to be running (useful for unifying results across many environments, e.g., with a `pyproject.toml` file).",
"description": "Enforce a requirement on the version of Ruff, to enforce at runtime. If the version of Ruff does not meet the requirement, Ruff will exit with an error.\n\nUseful for unifying results across many environments, e.g., with a `pyproject.toml` file.\n\nAccepts a PEP 440 specifier, like `==0.3.1` or `>=0.3.1`.",
"anyOf": [
{
"$ref": "#/definitions/Version"
"$ref": "#/definitions/RequiredVersion"
},
{
"type": "null"
@ -2590,6 +2590,9 @@
}
]
},
"RequiredVersion": {
"type": "string"
},
"RuleSelector": {
"type": "string",
"enum": [
@ -3870,9 +3873,6 @@
]
}
]
},
"Version": {
"type": "string"
}
}
}