Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 66 additions & 19 deletions isort/literal.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
)
from isort.settings import DEFAULT_CONFIG, Config

type_mapping: dict[str, tuple[type, Callable[[Any, Config, int], str]]] = {}
type_mapping: dict[str, tuple[type, Callable[[Any, Config, int, bool], str]]] = {}


def assignments(code: str) -> str:
Expand Down Expand Up @@ -42,6 +42,7 @@ def assignment(code: str, sort_type: str, extension: str, config: Config = DEFAU
variable_name, literal = code.split("=")
variable_name = variable_name.strip()
literal = literal.lstrip()
preserve_trailing_comma = config.include_trailing_comma and _has_trailing_comma(literal)
try:
value = ast.literal_eval(literal)
except Exception as error:
Expand All @@ -52,7 +53,9 @@ def assignment(code: str, sort_type: str, extension: str, config: Config = DEFAU
raise LiteralSortTypeMismatch(type(value), expected_type)

prefix_length = len(f"{variable_name} = ")
sorted_value_code = f"{variable_name} = {sort_function(value, config, prefix_length)}"
sorted_value_code = (
f"{variable_name} = {sort_function(value, config, prefix_length, preserve_trailing_comma)}"
)
if config.formatting_function:
sorted_value_code = config.formatting_function(
sorted_value_code, extension, config
Expand All @@ -64,18 +67,31 @@ def assignment(code: str, sort_type: str, extension: str, config: Config = DEFAU

def register_type(
name: str, kind: type
) -> Callable[[Callable[[Any, Config, int], str]], Callable[[Any, Config, int], str]]:
) -> Callable[[Callable[[Any, Config, int, bool], str]], Callable[[Any, Config, int, bool], str]]:
"""Registers a new literal sort type."""

def wrap(
function: Callable[[Any, Config, int], str],
) -> Callable[[Any, Config, int], str]:
function: Callable[[Any, Config, int, bool], str],
) -> Callable[[Any, Config, int, bool], str]:
type_mapping[name] = (kind, function)
return function

return wrap


def _has_trailing_comma(literal: str) -> bool:
"""Return True when a bracketed source literal uses a final trailing comma."""
literal = literal.rstrip()
if "\n" not in literal or not literal:
return False

close_bracket = {"(": ")", "[": "]", "{": "}"}.get(literal[0])
if close_bracket is None or not literal.endswith(close_bracket):
return False

return literal[:-1].rstrip().endswith(",")


def _black_quote(value: str) -> str:
"""Quote a string the way black does: prefer double quotes, fall back to single
only when it avoids escaping. Values with backslashes or control characters defer
Expand Down Expand Up @@ -106,6 +122,7 @@ def _format_collection(
close_bracket: str,
config: Config,
prefix_length: int,
preserve_trailing_comma: bool,
single_element_comma: bool = False,
) -> str:
"""Render already-rendered, sorted ``elements`` as ``open ... close`` honoring the
Expand All @@ -119,49 +136,79 @@ def _format_collection(
if only_element_needs_comma:
inner += ","
single_line = f"{open_bracket}{inner}{close_bracket}"
if prefix_length + len(single_line) <= config.line_length:
if not preserve_trailing_comma and prefix_length + len(single_line) <= config.line_length:
return single_line

indent = config.indent
trailing = "," if (config.include_trailing_comma or only_element_needs_comma) else ""
trailing = (
","
if (preserve_trailing_comma or config.include_trailing_comma or only_element_needs_comma)
else ""
)
body = (",\n" + indent).join(elements)
return f"{open_bracket}\n{indent}{body}{trailing}\n{close_bracket}"


@register_type("dict", dict)
def _dict(value: dict[Any, Any], config: Config, prefix_length: int) -> str:
def _dict(
value: dict[Any, Any], config: Config, prefix_length: int, preserve_trailing_comma: bool
) -> str:
items = [
f"{_repr_element(key)}: {_repr_element(item)}"
for key, item in sorted(value.items(), key=lambda item: item[1])
]
return _format_collection(items, "{", "}", config, prefix_length)
return _format_collection(items, "{", "}", config, prefix_length, preserve_trailing_comma)


@register_type("list", list)
def _list(value: list[Any], config: Config, prefix_length: int) -> str:
def _list(
value: list[Any], config: Config, prefix_length: int, preserve_trailing_comma: bool
) -> str:
elements = [_repr_element(item) for item in sorted(value)]
return _format_collection(elements, "[", "]", config, prefix_length)
return _format_collection(elements, "[", "]", config, prefix_length, preserve_trailing_comma)


@register_type("unique-list", list)
def _unique_list(value: list[Any], config: Config, prefix_length: int) -> str:
def _unique_list(
value: list[Any], config: Config, prefix_length: int, preserve_trailing_comma: bool
) -> str:
elements = [_repr_element(item) for item in sorted(set(value))]
return _format_collection(elements, "[", "]", config, prefix_length)
return _format_collection(elements, "[", "]", config, prefix_length, preserve_trailing_comma)


@register_type("set", set)
def _set(value: set[Any], config: Config, prefix_length: int) -> str:
def _set(value: set[Any], config: Config, prefix_length: int, preserve_trailing_comma: bool) -> str:
elements = [_repr_element(item) for item in sorted(value)]
return _format_collection(elements, "{", "}", config, prefix_length)
return _format_collection(elements, "{", "}", config, prefix_length, preserve_trailing_comma)


@register_type("tuple", tuple)
def _tuple(value: tuple[Any, ...], config: Config, prefix_length: int) -> str:
def _tuple(
value: tuple[Any, ...], config: Config, prefix_length: int, preserve_trailing_comma: bool
) -> str:
elements = [_repr_element(item) for item in sorted(value)]
return _format_collection(elements, "(", ")", config, prefix_length, single_element_comma=True)
return _format_collection(
elements,
"(",
")",
config,
prefix_length,
preserve_trailing_comma,
single_element_comma=True,
)


@register_type("unique-tuple", tuple)
def _unique_tuple(value: tuple[Any, ...], config: Config, prefix_length: int) -> str:
def _unique_tuple(
value: tuple[Any, ...], config: Config, prefix_length: int, preserve_trailing_comma: bool
) -> str:
elements = [_repr_element(item) for item in sorted(set(value))]
return _format_collection(elements, "(", ")", config, prefix_length, single_element_comma=True)
return _format_collection(
elements,
"(",
")",
config,
prefix_length,
preserve_trailing_comma,
single_element_comma=True,
)
16 changes: 16 additions & 0 deletions tests/unit/test_literal.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,22 @@ def test_wrap_without_trailing_comma():
assert result.endswith('"name_11"\n]') # no trailing comma before the closing bracket


def test_assignment_applies_formatting_function():
def formatting_function(code, extension, config):
assert extension == "py"
assert config.formatting_function is formatting_function
return code.replace('"a"', '"A"')

result = isort.literal.assignment(
"x = ['b', 'a']", "list", "py", config=Config(formatting_function=formatting_function)
)
assert result == 'x = ["A", "b"]'


def test_trailing_comma_detection_requires_matching_brackets():
assert not isort.literal._has_trailing_comma("(\n 'a',\n]")


def test_quote_fallback_for_embedded_quote():
# value containing a double quote but no single quote -> single quotes (black rule)
assert isort.literal.assignment("x = ['a\"b']", "list", "py") == "x = ['a\"b']"
Expand Down
30 changes: 30 additions & 0 deletions tests/unit/test_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -2227,6 +2227,36 @@ def test_sort_reexports_check_mode_multiline_all_issue_2280():
assert isort.check_code(checked, show_diff=False, profile="black", sort_reexports=True)


def test_sort_reexports_preserves_short_multiline_trailing_comma_issue_2578():
"""A short __all__ with a trailing comma should keep its explicit multiline style."""
test_input = """__all__ = (
"SecondClass",
"FirstClass",
)
"""
expected_output = """__all__ = (
"FirstClass",
"SecondClass",
)
"""
assert isort.code(test_input, profile="black", sort_reexports=True) == expected_output


def test_sort_reexports_preserves_short_multiline_list_trailing_comma_issue_2578():
"""The same trailing-comma preservation applies to list-style __all__ exports."""
test_input = """__all__ = [
"SecondClass",
"FirstClass",
]
"""
expected_output = """__all__ = [
"FirstClass",
"SecondClass",
]
"""
assert isort.code(test_input, profile="black", sort_reexports=True) == expected_output


def test_noqa_added_to_long_force_single_line_as_import_with_comment_issue_2093():
"""A long ``as`` import with inline comment must get ``# NOQA`` in NOQA mode.

Expand Down