[isort] Add support for length-sort settings (#8841)

## Summary

Closes #1567.

Add both `length-sort` and `length-sort-straight` settings for isort.

Here are a few notable points:
- The length is determined using the
[`unicode_width`](https://crates.io/crates/unicode-width) crate, i.e. we
are talking about displayed length (this is explicitly mentioned in the
description of the setting)
- The dots are taken into account in the length to be compatible with
the original isort
- I had to reorder a few fields of the module key struct for it all to
make sense (notably the `force_to_top` field is now the first one)

## Test Plan

I added tests for the following cases:
- Basic tests for length-sort with ASCII characters only
- Tests with non-ASCII characters
- Tests with relative imports
- Tests for length-sort-straight
This commit is contained in:
Joffrey Bluthé 2023-11-28 07:00:37 +01:00 committed by GitHub
parent ed14fd9163
commit 578ddf1bb1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 417 additions and 12 deletions

View file

@ -0,0 +1,3 @@
from mediuuuuuuuuuuum import a
from short import b
from loooooooooooooooooooooog import c

View file

@ -0,0 +1,11 @@
from module1 import (
loooooooooooooong,
σηορτ,
mediuuuuum,
shoort,
looooooooooooooong,
μεδιυυυυυμ,
short,
mediuuuuuum,
λοοοοοοοοοοοοοονγ,
)

View file

@ -0,0 +1,9 @@
import loooooooooooooong
import mediuuuuuum
import short
import σηορτ
import shoort
import mediuuuuum
import λοοοοοοοοοοοοοονγ
import μεδιυυυυυμ
import looooooooooooooong

View file

@ -0,0 +1,6 @@
import mediuuuuuum
import short
import looooooooooooooooong
from looooooooooooooong import a
from mediuuuum import c
from short import b

View file

@ -0,0 +1,4 @@
import mediuuuuuumb
import short
import looooooooooooooooong
import mediuuuuuuma

View file

@ -0,0 +1,7 @@
from ..looooooooooooooong import a
from ...mediuuuum import b
from .short import c
from ....short import c
from . import d
from .mediuuuum import a
from ......short import b

View file

@ -0,0 +1,3 @@
from looooooooooooooong import a
from mediuuuum import *
from short import *

View file

@ -1138,4 +1138,47 @@ mod tests {
assert_messages!(diagnostics);
Ok(())
}
#[test_case(Path::new("length_sort_straight_imports.py"))]
#[test_case(Path::new("length_sort_from_imports.py"))]
#[test_case(Path::new("length_sort_straight_and_from_imports.py"))]
#[test_case(Path::new("length_sort_non_ascii_members.py"))]
#[test_case(Path::new("length_sort_non_ascii_modules.py"))]
#[test_case(Path::new("length_sort_with_relative_imports.py"))]
fn length_sort(path: &Path) -> Result<()> {
let snapshot = format!("length_sort__{}", path.to_string_lossy());
let diagnostics = test_path(
Path::new("isort").join(path).as_path(),
&LinterSettings {
isort: super::settings::Settings {
length_sort: true,
..super::settings::Settings::default()
},
src: vec![test_resource_path("fixtures/isort")],
..LinterSettings::for_rule(Rule::UnsortedImports)
},
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}
#[test_case(Path::new("length_sort_straight_imports.py"))]
#[test_case(Path::new("length_sort_from_imports.py"))]
#[test_case(Path::new("length_sort_straight_and_from_imports.py"))]
fn length_sort_straight(path: &Path) -> Result<()> {
let snapshot = format!("length_sort_straight__{}", path.to_string_lossy());
let diagnostics = test_path(
Path::new("isort").join(path).as_path(),
&LinterSettings {
isort: super::settings::Settings {
length_sort_straight: true,
..super::settings::Settings::default()
},
src: vec![test_resource_path("fixtures/isort")],
..LinterSettings::for_rule(Rule::UnsortedImports)
},
)?;
assert_messages!(snapshot, diagnostics);
Ok(())
}
}

View file

@ -1,3 +1,4 @@
use crate::rules::isort::sorting::ImportStyle;
use itertools::Itertools;
use super::settings::Settings;
@ -56,21 +57,34 @@ pub(crate) fn order_imports<'a>(
.map(Import)
.chain(from_imports.map(ImportFrom))
.sorted_by_cached_key(|import| match import {
Import((alias, _)) => {
ModuleKey::from_module(Some(alias.name), alias.asname, None, None, settings)
}
Import((alias, _)) => ModuleKey::from_module(
Some(alias.name),
alias.asname,
None,
None,
ImportStyle::Straight,
settings,
),
ImportFrom((import_from, _, _, aliases)) => ModuleKey::from_module(
import_from.module,
None,
import_from.level,
aliases.first().map(|(alias, _)| (alias.name, alias.asname)),
ImportStyle::From,
settings,
),
})
.collect()
} else {
let ordered_straight_imports = straight_imports.sorted_by_cached_key(|(alias, _)| {
ModuleKey::from_module(Some(alias.name), alias.asname, None, None, settings)
ModuleKey::from_module(
Some(alias.name),
alias.asname,
None,
None,
ImportStyle::Straight,
settings,
)
});
let ordered_from_imports =
from_imports.sorted_by_cached_key(|(import_from, _, _, aliases)| {
@ -79,6 +93,7 @@ pub(crate) fn order_imports<'a>(
None,
import_from.level,
aliases.first().map(|(alias, _)| (alias.name, alias.asname)),
ImportStyle::From,
settings,
)
});

View file

@ -58,6 +58,8 @@ pub struct Settings {
pub section_order: Vec<ImportSection>,
pub no_sections: bool,
pub from_first: bool,
pub length_sort: bool,
pub length_sort_straight: bool,
}
impl Default for Settings {
@ -86,6 +88,8 @@ impl Default for Settings {
section_order: ImportType::iter().map(ImportSection::Known).collect(),
no_sections: false,
from_first: false,
length_sort: false,
length_sort_straight: false,
}
}
}

View file

@ -0,0 +1,18 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
length_sort_from_imports.py:1:1: I001 [*] Import block is un-sorted or un-formatted
|
1 | / from mediuuuuuuuuuuum import a
2 | | from short import b
3 | | from loooooooooooooooooooooog import c
|
= help: Organize imports
Safe fix
1 |+from short import b
1 2 | from mediuuuuuuuuuuum import a
2 |-from short import b
3 3 | from loooooooooooooooooooooog import c

View file

@ -0,0 +1,37 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
length_sort_non_ascii_members.py:1:1: I001 [*] Import block is un-sorted or un-formatted
|
1 | / from module1 import (
2 | | loooooooooooooong,
3 | | σηορτ,
4 | | mediuuuuum,
5 | | shoort,
6 | | looooooooooooooong,
7 | | μεδιυυυυυμ,
8 | | short,
9 | | mediuuuuuum,
10 | | λοοοοοοοοοοοοοονγ,
11 | | )
|
= help: Organize imports
Safe fix
1 1 | from module1 import (
2 |- loooooooooooooong,
2 |+ short,
3 3 | σηορτ,
4 |+ shoort,
4 5 | mediuuuuum,
5 |- shoort,
6 |- looooooooooooooong,
7 6 | μεδιυυυυυμ,
8 |- short,
9 7 | mediuuuuuum,
8 |+ loooooooooooooong,
10 9 | λοοοοοοοοοοοοοονγ,
10 |+ looooooooooooooong,
11 11 | )

View file

@ -0,0 +1,32 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
length_sort_non_ascii_modules.py:1:1: I001 [*] Import block is un-sorted or un-formatted
|
1 | / import loooooooooooooong
2 | | import mediuuuuuum
3 | | import short
4 | | import σηορτ
5 | | import shoort
6 | | import mediuuuuum
7 | | import λοοοοοοοοοοοοοονγ
8 | | import μεδιυυυυυμ
9 | | import looooooooooooooong
|
= help: Organize imports
Safe fix
1 |-import loooooooooooooong
2 |-import mediuuuuuum
3 1 | import short
4 2 | import σηορτ
5 3 | import shoort
6 4 | import mediuuuuum
5 |+import μεδιυυυυυμ
6 |+import mediuuuuuum
7 |+import loooooooooooooong
7 8 | import λοοοοοοοοοοοοοονγ
8 |-import μεδιυυυυυμ
9 9 | import looooooooooooooong

View file

@ -0,0 +1,26 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
length_sort_straight_and_from_imports.py:1:1: I001 [*] Import block is un-sorted or un-formatted
|
1 | / import mediuuuuuum
2 | | import short
3 | | import looooooooooooooooong
4 | | from looooooooooooooong import a
5 | | from mediuuuum import c
6 | | from short import b
|
= help: Organize imports
Safe fix
1 |+import short
1 2 | import mediuuuuuum
2 |-import short
3 3 | import looooooooooooooooong
4 |-from looooooooooooooong import a
4 |+from short import b
5 5 | from mediuuuum import c
6 |-from short import b
6 |+from looooooooooooooong import a

View file

@ -0,0 +1,21 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
length_sort_straight_imports.py:1:1: I001 [*] Import block is un-sorted or un-formatted
|
1 | / import mediuuuuuumb
2 | | import short
3 | | import looooooooooooooooong
4 | | import mediuuuuuuma
|
= help: Organize imports
Safe fix
1 |+import short
2 |+import mediuuuuuuma
1 3 | import mediuuuuuumb
2 |-import short
3 4 | import looooooooooooooooong
4 |-import mediuuuuuuma

View file

@ -0,0 +1,28 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
length_sort_with_relative_imports.py:1:1: I001 [*] Import block is un-sorted or un-formatted
|
1 | / from ..looooooooooooooong import a
2 | | from ...mediuuuum import b
3 | | from .short import c
4 | | from ....short import c
5 | | from . import d
6 | | from .mediuuuum import a
7 | | from ......short import b
|
= help: Organize imports
Safe fix
1 |-from ..looooooooooooooong import a
2 |-from ...mediuuuum import b
1 |+from . import d
3 2 | from .short import c
4 3 | from ....short import c
5 |-from . import d
6 4 | from .mediuuuum import a
7 5 | from ......short import b
6 |+from ...mediuuuum import b
7 |+from ..looooooooooooooong import a

View file

@ -0,0 +1,18 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
length_sort_from_imports.py:1:1: I001 [*] Import block is un-sorted or un-formatted
|
1 | / from mediuuuuuuuuuuum import a
2 | | from short import b
3 | | from loooooooooooooooooooooog import c
|
= help: Organize imports
Safe fix
1 |+from loooooooooooooooooooooog import c
1 2 | from mediuuuuuuuuuuum import a
2 3 | from short import b
3 |-from loooooooooooooooooooooog import c

View file

@ -0,0 +1,23 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
length_sort_straight_and_from_imports.py:1:1: I001 [*] Import block is un-sorted or un-formatted
|
1 | / import mediuuuuuum
2 | | import short
3 | | import looooooooooooooooong
4 | | from looooooooooooooong import a
5 | | from mediuuuum import c
6 | | from short import b
|
= help: Organize imports
Safe fix
1 |+import short
1 2 | import mediuuuuuum
2 |-import short
3 3 | import looooooooooooooooong
4 4 | from looooooooooooooong import a
5 5 | from mediuuuum import c

View file

@ -0,0 +1,21 @@
---
source: crates/ruff_linter/src/rules/isort/mod.rs
---
length_sort_straight_imports.py:1:1: I001 [*] Import block is un-sorted or un-formatted
|
1 | / import mediuuuuuumb
2 | | import short
3 | | import looooooooooooooooong
4 | | import mediuuuuuuma
|
= help: Organize imports
Safe fix
1 |+import short
2 |+import mediuuuuuuma
1 3 | import mediuuuuuumb
2 |-import short
3 4 | import looooooooooooooooong
4 |-import mediuuuuuuma

View file

@ -3,6 +3,7 @@
use std::{borrow::Cow, cmp::Ordering, cmp::Reverse};
use natord;
use unicode_width::UnicodeWidthStr;
use ruff_python_stdlib::str;
@ -64,18 +65,27 @@ impl<'a> From<String> for NatOrdStr<'a> {
}
}
#[derive(Debug, PartialOrd, Ord, PartialEq, Eq)]
#[derive(Debug, Copy, Clone, PartialOrd, Ord, PartialEq, Eq)]
pub(crate) enum Distance {
Nearest(u32),
Furthest(Reverse<u32>),
}
#[derive(Debug, Copy, Clone, PartialOrd, Ord, PartialEq, Eq)]
pub(crate) enum ImportStyle {
// Ex) `import foo`
Straight,
// Ex) `from foo import bar`
From,
}
/// A comparable key to capture the desired sorting order for an imported module (e.g.,
/// `foo` in `from foo import bar`).
#[derive(Debug, PartialOrd, Ord, PartialEq, Eq)]
pub(crate) struct ModuleKey<'a> {
force_to_top: bool,
maybe_length: Option<usize>,
distance: Distance,
force_to_top: Option<bool>,
maybe_lowercase_name: Option<NatOrdStr<'a>>,
module_name: Option<NatOrdStr<'a>>,
first_alias: Option<MemberKey<'a>>,
@ -88,26 +98,39 @@ impl<'a> ModuleKey<'a> {
asname: Option<&'a str>,
level: Option<u32>,
first_alias: Option<(&'a str, Option<&'a str>)>,
style: ImportStyle,
settings: &Settings,
) -> Self {
let level = level.unwrap_or_default();
let force_to_top = !name
.map(|name| settings.force_to_top.contains(name))
.unwrap_or_default(); // `false` < `true` so we get forced to top first
let maybe_length = (settings.length_sort
|| (settings.length_sort_straight && style == ImportStyle::Straight))
.then_some(name.map(str::width).unwrap_or_default() + level as usize);
let distance = match settings.relative_imports_order {
RelativeImportsOrder::ClosestToFurthest => Distance::Nearest(level.unwrap_or_default()),
RelativeImportsOrder::FurthestToClosest => {
Distance::Furthest(Reverse(level.unwrap_or_default()))
}
RelativeImportsOrder::ClosestToFurthest => Distance::Nearest(level),
RelativeImportsOrder::FurthestToClosest => Distance::Furthest(Reverse(level)),
};
let force_to_top = name.map(|name| !settings.force_to_top.contains(name)); // `false` < `true` so we get forced to top first
let maybe_lowercase_name = name.and_then(|name| {
(!settings.case_sensitive).then_some(NatOrdStr(maybe_lowercase(name)))
});
let module_name = name.map(NatOrdStr::from);
let asname = asname.map(NatOrdStr::from);
let first_alias =
first_alias.map(|(name, asname)| MemberKey::from_member(name, asname, settings));
Self {
distance,
force_to_top,
maybe_length,
distance,
maybe_lowercase_name,
module_name,
first_alias,
@ -122,6 +145,7 @@ impl<'a> ModuleKey<'a> {
pub(crate) struct MemberKey<'a> {
not_star_import: bool,
member_type: Option<MemberType>,
maybe_length: Option<usize>,
maybe_lowercase_name: Option<NatOrdStr<'a>>,
module_name: NatOrdStr<'a>,
asname: Option<NatOrdStr<'a>>,
@ -133,6 +157,7 @@ impl<'a> MemberKey<'a> {
let member_type = settings
.order_by_type
.then_some(member_type(name, settings));
let maybe_length = settings.length_sort.then_some(name.width());
let maybe_lowercase_name =
(!settings.case_sensitive).then_some(NatOrdStr(maybe_lowercase(name)));
let module_name = NatOrdStr::from(name);
@ -141,6 +166,7 @@ impl<'a> MemberKey<'a> {
Self {
not_star_import,
member_type,
maybe_length,
maybe_lowercase_name,
module_name,
asname,

View file

@ -2047,6 +2047,40 @@ pub struct IsortOptions {
)]
pub from_first: Option<bool>,
/// Sort imports by their string length, such that shorter imports appear
/// before longer imports. For example, by default, imports will be sorted
/// alphabetically, as in:
/// ```python
/// import collections
/// import os
/// ```
///
/// Setting `length-sort = true` will instead sort such that shorter imports
/// appear before longer imports, as in:
/// ```python
/// import os
/// import collections
/// ```
#[option(
default = r#"false"#,
value_type = "bool",
example = r#"
length-sort = true
"#
)]
pub length_sort: Option<bool>,
/// Sort straight imports by their string length. Similar to `length-sort`,
/// but applies only to straight imports and doesn't affect `from` imports.
#[option(
default = r#"false"#,
value_type = "bool",
example = r#"
length-sort-straight = true
"#
)]
pub length_sort_straight: Option<bool>,
// Tables are required to go last.
/// A list of mappings from section names to modules.
/// By default custom sections are output last, but this can be overridden with `section-order`.
@ -2234,6 +2268,8 @@ impl IsortOptions {
section_order,
no_sections,
from_first,
length_sort: self.length_sort.unwrap_or(false),
length_sort_straight: self.length_sort_straight.unwrap_or(false),
})
}
}

14
ruff.schema.json generated
View file

@ -1477,6 +1477,20 @@
"type": "string"
}
},
"length-sort": {
"description": "Sort imports by their string length, such that shorter imports appear before longer imports. For example, by default, imports will be sorted alphabetically, as in: ```python import collections import os ```\n\nSetting `length-sort = true` will instead sort such that shorter imports appear before longer imports, as in: ```python import os import collections ```",
"type": [
"boolean",
"null"
]
},
"length-sort-straight": {
"description": "Sort straight imports by their string length. Similar to `length-sort`, but applies only to straight imports and doesn't affect `from` imports.",
"type": [
"boolean",
"null"
]
},
"lines-after-imports": {
"description": "The number of blank lines to place after imports. Use `-1` for automatic determination.\n\nWhen using the formatter, only the values `-1`, `1`, and `2` are compatible because it enforces at least one empty and at most two empty lines after imports.",
"type": [