diff --git a/README.md b/README.md index 2aca78817f..b1377b2924 100644 --- a/README.md +++ b/README.md @@ -3135,6 +3135,24 @@ known-third-party = ["src"] --- +#### [`no-lines-before`](#no-lines-before) + +A list of sections that should _not_ be delineated from the previous +section via empty lines. + +**Default value**: `[]` + +**Type**: `Option>` + +**Example usage**: + +```toml +[tool.ruff.isort] +no-lines-before = ["future", "standard-library"] +``` + +--- + #### [`order-by-type`](#order-by-type) Order imports by type, which is determined by case, in addition to diff --git a/resources/test/fixtures/isort/no_lines_before.py b/resources/test/fixtures/isort/no_lines_before.py new file mode 100644 index 0000000000..b4cc530175 --- /dev/null +++ b/resources/test/fixtures/isort/no_lines_before.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +from typing import Any + +from requests import Session + +from my_first_party import my_first_party_object + +from . import my_local_folder_object diff --git a/ruff.schema.json b/ruff.schema.json index fd45526b3d..821cd7cf43 100644 --- a/ruff.schema.json +++ b/ruff.schema.json @@ -762,6 +762,16 @@ }, "additionalProperties": false }, + "ImportType": { + "type": "string", + "enum": [ + "future", + "standard-library", + "third-party", + "first-party", + "local-folder" + ] + }, "IsortOptions": { "type": "object", "properties": { @@ -843,6 +853,16 @@ "type": "string" } }, + "no-lines-before": { + "description": "A list of sections that should _not_ be delineated from the previous section via empty lines.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/ImportType" + } + }, "order-by-type": { "description": "Order imports by type, which is determined by case, in addition to alphabetically.", "type": [ diff --git a/src/rules/isort/categorize.rs b/src/rules/isort/categorize.rs index 627b6df42f..24b4e99567 100644 --- a/src/rules/isort/categorize.rs +++ b/src/rules/isort/categorize.rs @@ -3,10 +3,15 @@ use std::fs; use std::path::{Path, PathBuf}; use log::debug; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use crate::python::sys::KNOWN_STANDARD_LIBRARY; -#[derive(Debug, PartialOrd, Ord, PartialEq, Eq, Clone)] +#[derive( + Debug, PartialOrd, Ord, PartialEq, Eq, Clone, Serialize, Deserialize, JsonSchema, Hash, +)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] pub enum ImportType { Future, StandardLibrary, diff --git a/src/rules/isort/mod.rs b/src/rules/isort/mod.rs index 60b80a0064..50b5218735 100644 --- a/src/rules/isort/mod.rs +++ b/src/rules/isort/mod.rs @@ -575,6 +575,7 @@ pub fn format_imports( classes: &BTreeSet, constants: &BTreeSet, variables: &BTreeSet, + no_lines_before: &BTreeSet, ) -> String { let trailer = &block.trailer; let block = annotate_imports(&block.imports, comments, locator, split_on_trailing_comma); @@ -596,7 +597,7 @@ pub fn format_imports( // Generate replacement source code. let mut is_first_block = true; - for import_block in block_by_type.into_values() { + for (import_type, import_block) in block_by_type { let mut imports = order_imports( import_block, order_by_type, @@ -628,7 +629,7 @@ pub fn format_imports( // Add a blank line between every section. if is_first_block { is_first_block = false; - } else { + } else if !no_lines_before.contains(&import_type) { output.append(stylist.line_ending()); } @@ -680,6 +681,7 @@ mod tests { use anyhow::Result; use test_case::test_case; + use super::categorize::ImportType; use super::settings::RelatveImportsOrder; use crate::linter::test_path; use crate::registry::RuleCode; @@ -726,6 +728,7 @@ mod tests { #[test_case(Path::new("order_by_type_with_custom_classes.py"))] #[test_case(Path::new("order_by_type_with_custom_constants.py"))] #[test_case(Path::new("order_by_type_with_custom_variables.py"))] + #[test_case(Path::new("no_lines_before.py"))] fn default(path: &Path) -> Result<()> { let snapshot = format!("{}", path.to_string_lossy()); let diagnostics = test_path( @@ -1073,4 +1076,31 @@ mod tests { insta::assert_yaml_snapshot!(snapshot, diagnostics); Ok(()) } + + #[test_case(Path::new("no_lines_before.py"))] + fn no_lines_before(path: &Path) -> Result<()> { + let snapshot = format!("no_lines_before.py_{}", path.to_string_lossy()); + let mut diagnostics = test_path( + Path::new("./resources/test/fixtures/isort") + .join(path) + .as_path(), + &Settings { + isort: super::settings::Settings { + no_lines_before: BTreeSet::from([ + ImportType::Future, + ImportType::StandardLibrary, + ImportType::ThirdParty, + ImportType::FirstParty, + ImportType::LocalFolder, + ]), + ..super::settings::Settings::default() + }, + src: vec![Path::new("resources/test/fixtures/isort").to_path_buf()], + ..Settings::for_rule(RuleCode::I001) + }, + )?; + diagnostics.sort_by_key(|diagnostic| diagnostic.location); + insta::assert_yaml_snapshot!(snapshot, diagnostics); + Ok(()) + } } diff --git a/src/rules/isort/rules/organize_imports.rs b/src/rules/isort/rules/organize_imports.rs index 167d168e24..23408c71a9 100644 --- a/src/rules/isort/rules/organize_imports.rs +++ b/src/rules/isort/rules/organize_imports.rs @@ -88,6 +88,7 @@ pub fn organize_imports( &settings.isort.classes, &settings.isort.constants, &settings.isort.variables, + &settings.isort.no_lines_before, ); // Expand the span the entire range, including leading and trailing space. diff --git a/src/rules/isort/settings.rs b/src/rules/isort/settings.rs index 5c48d952c4..303d6b6e3c 100644 --- a/src/rules/isort/settings.rs +++ b/src/rules/isort/settings.rs @@ -6,6 +6,8 @@ use ruff_macros::ConfigurationOptions; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use super::categorize::ImportType; + #[derive(Debug, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, JsonSchema)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] pub enum RelatveImportsOrder { @@ -201,6 +203,16 @@ pub struct Options { /// An override list of tokens to always recognize as a var /// for `order-by-type` regardless of casing. pub variables: Option>, + #[option( + default = r#"[]"#, + value_type = "Option>", + example = r#" + no-lines-before = ["future", "standard-library"] + "# + )] + /// A list of sections that should _not_ be delineated from the previous + /// section via empty lines. + pub no_lines_before: Option>, } #[derive(Debug, Hash)] @@ -221,6 +233,7 @@ pub struct Settings { pub classes: BTreeSet, pub constants: BTreeSet, pub variables: BTreeSet, + pub no_lines_before: BTreeSet, } impl Default for Settings { @@ -241,6 +254,7 @@ impl Default for Settings { classes: BTreeSet::new(), constants: BTreeSet::new(), variables: BTreeSet::new(), + no_lines_before: BTreeSet::new(), } } } @@ -267,6 +281,7 @@ impl From for Settings { classes: BTreeSet::from_iter(options.classes.unwrap_or_default()), constants: BTreeSet::from_iter(options.constants.unwrap_or_default()), variables: BTreeSet::from_iter(options.variables.unwrap_or_default()), + no_lines_before: BTreeSet::from_iter(options.no_lines_before.unwrap_or_default()), } } } @@ -289,6 +304,7 @@ impl From for Options { classes: Some(settings.classes.into_iter().collect()), constants: Some(settings.constants.into_iter().collect()), variables: Some(settings.variables.into_iter().collect()), + no_lines_before: Some(settings.no_lines_before.into_iter().collect()), } } } diff --git a/src/rules/isort/snapshots/ruff__rules__isort__tests__no_lines_before.py.snap b/src/rules/isort/snapshots/ruff__rules__isort__tests__no_lines_before.py.snap new file mode 100644 index 0000000000..3d39d5ac18 --- /dev/null +++ b/src/rules/isort/snapshots/ruff__rules__isort__tests__no_lines_before.py.snap @@ -0,0 +1,22 @@ +--- +source: src/rules/isort/mod.rs +expression: diagnostics +--- +- kind: + UnsortedImports: ~ + location: + row: 1 + column: 0 + end_location: + row: 10 + column: 0 + fix: + content: "from __future__ import annotations\n\nfrom typing import Any\n\nfrom my_first_party import my_first_party_object\nfrom requests import Session\n\nfrom . import my_local_folder_object\n" + location: + row: 1 + column: 0 + end_location: + row: 10 + column: 0 + parent: ~ + diff --git a/src/rules/isort/snapshots/ruff__rules__isort__tests__no_lines_before.py_no_lines_before.py.snap b/src/rules/isort/snapshots/ruff__rules__isort__tests__no_lines_before.py_no_lines_before.py.snap new file mode 100644 index 0000000000..67dc87d773 --- /dev/null +++ b/src/rules/isort/snapshots/ruff__rules__isort__tests__no_lines_before.py_no_lines_before.py.snap @@ -0,0 +1,22 @@ +--- +source: src/rules/isort/mod.rs +expression: diagnostics +--- +- kind: + UnsortedImports: ~ + location: + row: 1 + column: 0 + end_location: + row: 10 + column: 0 + fix: + content: "from __future__ import annotations\nfrom typing import Any\nfrom my_first_party import my_first_party_object\nfrom requests import Session\nfrom . import my_local_folder_object\n" + location: + row: 1 + column: 0 + end_location: + row: 10 + column: 0 + parent: ~ +