diff --git a/crates/ruff/tests/lint.rs b/crates/ruff/tests/lint.rs index f6c4072d1c617..b53f219351736 100644 --- a/crates/ruff/tests/lint.rs +++ b/crates/ruff/tests/lint.rs @@ -1618,3 +1618,189 @@ print( Ok(()) } + +/// Infer `3.11` from `requires-python` in `pyproject.toml`. +#[test] +fn requires_python() -> Result<()> { + let tempdir = TempDir::new()?; + let ruff_toml = tempdir.path().join("pyproject.toml"); + fs::write( + &ruff_toml, + r#"[project] +requires-python = ">= 3.11" + +[tool.ruff.lint] +select = ["UP006"] +"#, + )?; + + insta::with_settings!({ + filters => vec![(tempdir_filter(&tempdir).as_str(), "[TMP]/")] + }, { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(STDIN_BASE_OPTIONS) + .arg("--config") + .arg(&ruff_toml) + .args(["--stdin-filename", "test.py"]) + .arg("-") + .pass_stdin(r#"from typing import List; foo: List[int]"#), @r###" + success: false + exit_code: 1 + ----- stdout ----- + test.py:1:31: UP006 [*] Use `list` instead of `List` for type annotation + Found 1 error. + [*] 1 fixable with the `--fix` option. + + ----- stderr ----- + "###); + }); + + let pyproject_toml = tempdir.path().join("pyproject.toml"); + fs::write( + &pyproject_toml, + r#"[project] +requires-python = ">= 3.8" + +[tool.ruff.lint] +select = ["UP006"] +"#, + )?; + + insta::with_settings!({ + filters => vec![(tempdir_filter(&tempdir).as_str(), "[TMP]/")] + }, { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(STDIN_BASE_OPTIONS) + .arg("--config") + .arg(&pyproject_toml) + .args(["--stdin-filename", "test.py"]) + .arg("-") + .pass_stdin(r#"from typing import List; foo: List[int]"#), @r###" + success: true + exit_code: 0 + ----- stdout ----- + All checks passed! + + ----- stderr ----- + "###); + }); + + Ok(()) +} + +/// Infer `3.11` from `requires-python` in `pyproject.toml`. +#[test] +fn requires_python_patch() -> Result<()> { + let tempdir = TempDir::new()?; + let pyproject_toml = tempdir.path().join("pyproject.toml"); + fs::write( + &pyproject_toml, + r#"[project] +requires-python = ">= 3.11.4" + +[tool.ruff.lint] +select = ["UP006"] +"#, + )?; + + insta::with_settings!({ + filters => vec![(tempdir_filter(&tempdir).as_str(), "[TMP]/")] + }, { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(STDIN_BASE_OPTIONS) + .arg("--config") + .arg(&pyproject_toml) + .args(["--stdin-filename", "test.py"]) + .arg("-") + .pass_stdin(r#"from typing import List; foo: List[int]"#), @r###" + success: false + exit_code: 1 + ----- stdout ----- + test.py:1:31: UP006 [*] Use `list` instead of `List` for type annotation + Found 1 error. + [*] 1 fixable with the `--fix` option. + + ----- stderr ----- + "###); + }); + + Ok(()) +} + +/// Infer `3.11` from `requires-python` in `pyproject.toml`. +#[test] +fn requires_python_equals() -> Result<()> { + let tempdir = TempDir::new()?; + let pyproject_toml = tempdir.path().join("pyproject.toml"); + fs::write( + &pyproject_toml, + r#"[project] +requires-python = "== 3.11" + +[tool.ruff.lint] +select = ["UP006"] +"#, + )?; + + insta::with_settings!({ + filters => vec![(tempdir_filter(&tempdir).as_str(), "[TMP]/")] + }, { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(STDIN_BASE_OPTIONS) + .arg("--config") + .arg(&pyproject_toml) + .args(["--stdin-filename", "test.py"]) + .arg("-") + .pass_stdin(r#"from typing import List; foo: List[int]"#), @r###" + success: false + exit_code: 1 + ----- stdout ----- + test.py:1:31: UP006 [*] Use `list` instead of `List` for type annotation + Found 1 error. + [*] 1 fixable with the `--fix` option. + + ----- stderr ----- + "###); + }); + + Ok(()) +} + +/// Infer `3.11` from `requires-python` in `pyproject.toml`. +#[test] +fn requires_python_equals_patch() -> Result<()> { + let tempdir = TempDir::new()?; + let pyproject_toml = tempdir.path().join("pyproject.toml"); + fs::write( + &pyproject_toml, + r#"[project] +requires-python = "== 3.11.4" + +[tool.ruff.lint] +select = ["UP006"] +"#, + )?; + + insta::with_settings!({ + filters => vec![(tempdir_filter(&tempdir).as_str(), "[TMP]/")] + }, { + assert_cmd_snapshot!(Command::new(get_cargo_bin(BIN_NAME)) + .args(STDIN_BASE_OPTIONS) + .arg("--config") + .arg(&pyproject_toml) + .args(["--stdin-filename", "test.py"]) + .arg("-") + .pass_stdin(r#"from typing import List; foo: List[int]"#), @r###" + success: false + exit_code: 1 + ----- stdout ----- + test.py:1:31: UP006 [*] Use `list` instead of `List` for type annotation + Found 1 error. + [*] 1 fixable with the `--fix` option. + + ----- stderr ----- + "###); + }); + + Ok(()) +} diff --git a/crates/ruff_linter/src/settings/types.rs b/crates/ruff_linter/src/settings/types.rs index a5c2dae9c452e..4b632dd5ee15a 100644 --- a/crates/ruff_linter/src/settings/types.rs +++ b/crates/ruff_linter/src/settings/types.rs @@ -9,7 +9,8 @@ use std::string::ToString; use anyhow::{bail, Result}; use globset::{Glob, GlobMatcher, GlobSet, GlobSetBuilder}; -use pep440_rs::{Version as Pep440Version, VersionSpecifier, VersionSpecifiers}; +use log::debug; +use pep440_rs::{Operator, Version as Pep440Version, Version, VersionSpecifier, VersionSpecifiers}; use rustc_hash::FxHashMap; use serde::{de, Deserialize, Deserializer, Serialize}; use strum::IntoEnumIterator; @@ -59,7 +60,7 @@ pub enum PythonVersion { impl From for Pep440Version { fn from(version: PythonVersion) -> Self { let (major, minor) = version.as_tuple(); - Self::from_str(&format!("{major}.{minor}.100")).unwrap() + Self::new([u64::from(major), u64::from(minor)]) } } @@ -89,18 +90,36 @@ impl PythonVersion { self.as_tuple().1 } + /// Infer the minimum supported [`PythonVersion`] from a `requires-python` specifier. pub fn get_minimum_supported_version(requires_version: &VersionSpecifiers) -> Option { - let mut minimum_version = None; - for python_version in PythonVersion::iter() { - if requires_version - .iter() - .all(|specifier| specifier.contains(&python_version.into())) - { - minimum_version = Some(python_version); - break; - } + /// Truncate a version to its major and minor components. + fn major_minor(version: &Version) -> Option { + let major = version.release().first()?; + let minor = version.release().get(1)?; + Some(Version::new([major, minor])) } - minimum_version + + // Extract the minimum supported version from the specifiers. + let minimum_version = requires_version + .iter() + .filter(|specifier| { + matches!( + specifier.operator(), + Operator::Equal + | Operator::EqualStar + | Operator::ExactEqual + | Operator::TildeEqual + | Operator::GreaterThan + | Operator::GreaterThanEqual + ) + }) + .filter_map(|specifier| major_minor(specifier.version())) + .min()?; + + debug!("Detected minimum supported `requires-python` version: {minimum_version}"); + + // Find the Python version that matches the minimum supported version. + PythonVersion::iter().find(|version| Version::from(*version) == minimum_version) } /// Return `true` if the current version supports [PEP 701].