Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Isort orders module-level dunders #2060

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
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
26 changes: 25 additions & 1 deletion isort/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import isort.literal
from isort.settings import DEFAULT_CONFIG, Config
from isort.utils import is_module_dunder

from . import output, parse
from .exceptions import ExistingSyntaxErrors, FileSkipComment
Expand Down Expand Up @@ -196,7 +197,11 @@ def process(
first_comment_index_end = index - 1

was_in_quote = bool(in_quote)
if (not stripped_line.startswith("#") or in_quote) and '"' in line or "'" in line:
if (
not is_module_dunder(stripped_line)
and (not stripped_line.startswith("#") or in_quote)
and ('"' in line or "'" in line)
):
char_index = 0
if first_comment_index_start == -1 and (
line.startswith('"') or line.startswith("'")
Expand All @@ -222,6 +227,7 @@ def process(
char_index += 1

not_imports = bool(in_quote) or was_in_quote or in_top_comment or isort_off

if not (in_quote or was_in_quote or in_top_comment):
if isort_off:
if not skip_file and stripped_line == "# isort: on":
Expand Down Expand Up @@ -288,13 +294,30 @@ def process(
and stripped_line not in config.treat_comments_as_code
):
import_section += line
elif is_module_dunder(stripped_line):
# Handle module-level dunders.
dunder_statement = line
if stripped_line.endswith(("\\", "[", '= """', "= '''")):
# Handle multiline module dunder assignments.
while (
stripped_line
and not stripped_line.endswith("]")
and stripped_line != '"""'
and stripped_line != "'''"
):
line = input_stream.readline()
stripped_line = line.strip()
dunder_statement += line
import_section += dunder_statement

elif stripped_line.startswith(IMPORT_START_IDENTIFIERS):
new_indent = line[: -len(line.lstrip())]
import_statement = line
stripped_line = line.strip().split("#")[0]
while stripped_line.endswith("\\") or (
"(" in stripped_line and ")" not in stripped_line
):
# Handle multiline import statements.
if stripped_line.endswith("\\"):
while stripped_line and stripped_line.endswith("\\"):
line = input_stream.readline()
Expand Down Expand Up @@ -429,6 +452,7 @@ def process(
extension,
import_type="cimport" if cimports else "import",
)

if not (import_section.strip() and not sorted_import_section):
if indent:
sorted_import_section = (
Expand Down
10 changes: 10 additions & 0 deletions isort/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,12 @@ def sorted_imports(
parsed.imports["no_sections"] = {"straight": {}, "from": {}}
base_sections: Tuple[str, ...] = ()
for section in sections:
if section == "DUNDER":
continue
if section == "FUTURE":
base_sections = ("FUTURE",)
continue

parsed.imports["no_sections"]["straight"].update(
parsed.imports[section].get("straight", {})
)
Expand All @@ -46,7 +49,14 @@ def sorted_imports(
output: List[str] = []
seen_headings: Set[str] = set()
pending_lines_before = False

for section in sections:
if section == "DUNDER":
if parsed.module_dunders:
output += [""] * config.lines_between_sections
output.extend(parsed.module_dunders)
continue

straight_modules = parsed.imports[section]["straight"]
if not config.only_sections:
straight_modules = sorting.sort(
Expand Down
29 changes: 27 additions & 2 deletions isort/parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from typing import TYPE_CHECKING, Any, Dict, List, NamedTuple, Optional, Set, Tuple
from warnings import warn

from isort.utils import is_module_dunder

from . import place
from .comments import parse as parse_comments
from .exceptions import MissingSection
Expand Down Expand Up @@ -132,6 +134,7 @@ class ParsedContent(NamedTuple):
import_placements: Dict[str, str]
as_map: Dict[str, Dict[str, List[str]]]
imports: Dict[str, Dict[str, Any]]
module_dunders: List[str]
categorized_comments: "CommentsDict"
change_count: int
original_line_count: int
Expand Down Expand Up @@ -166,9 +169,12 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
"from": defaultdict(list),
}
imports: OrderedDict[str, Dict[str, Any]] = OrderedDict()
module_dunders: List[str] = []
verbose_output: List[str] = []

for section in chain(config.sections, config.forced_separate):
section_names = [name for name in config.sections if name != "DUNDER"]

for section in chain(section_names, config.forced_separate):
imports[section] = {"straight": OrderedDict(), "from": OrderedDict()}
categorized_comments: CommentsDict = {
"from": {},
Expand All @@ -185,6 +191,23 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
while index < line_count:
line = in_lines[index]
index += 1

if is_module_dunder(line):
dunder_statement = line
if line.endswith(("\\", "[", '= """', "= '''")):
while (
index < line_count
and line
and not line.endswith("]")
and line != '"""'
and line != "'''"
):
line = in_lines[index]
index += 1
dunder_statement += "\n" + line
module_dunders.append(dunder_statement)
continue

statement_index = index
(skipping_line, in_quote) = skip_line(
line, in_quote=in_quote, index=index, section_comments=config.section_comments
Expand Down Expand Up @@ -265,8 +288,9 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte

for statement in statements:
line, raw_line = _normalize_line(statement)
type_of_import = import_type(line, config) or ""
raw_lines = [raw_line]
type_of_import = import_type(line, config) or ""

if not type_of_import:
out_lines.append(raw_line)
continue
Expand Down Expand Up @@ -587,6 +611,7 @@ def file_contents(contents: str, config: Config = DEFAULT_CONFIG) -> ParsedConte
import_placements=import_placements,
as_map=as_map,
imports=imports,
module_dunders=module_dunders,
categorized_comments=categorized_comments,
change_count=change_count,
original_line_count=original_line_count,
Expand Down
3 changes: 2 additions & 1 deletion isort/sections.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
from typing import Tuple

FUTURE: str = "FUTURE"
DUNDER: str = "DUNDER"
STDLIB: str = "STDLIB"
THIRDPARTY: str = "THIRDPARTY"
FIRSTPARTY: str = "FIRSTPARTY"
LOCALFOLDER: str = "LOCALFOLDER"
DEFAULT: Tuple[str, ...] = (FUTURE, STDLIB, THIRDPARTY, FIRSTPARTY, LOCALFOLDER)
DEFAULT: Tuple[str, ...] = (FUTURE, DUNDER, STDLIB, THIRDPARTY, FIRSTPARTY, LOCALFOLDER)
8 changes: 8 additions & 0 deletions isort/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
Expand Down Expand Up @@ -70,3 +71,10 @@ def exists_case_sensitive(path: str) -> bool:
directory, basename = os.path.split(path)
result = basename in os.listdir(directory)
return result


MODULE_DUNDER_PATTERN = re.compile(r"^__.*__\s*=")


def is_module_dunder(line: str) -> bool:
return bool(MODULE_DUNDER_PATTERN.match(line))
16 changes: 7 additions & 9 deletions tests/unit/profiles/test_attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ def test_attrs_code_snippet_one():
attrs_isort_test(
"""from __future__ import absolute_import, division, print_function

__version__ = "20.2.0.dev0"

import sys

from functools import partial
Expand All @@ -28,9 +30,6 @@ def test_attrs_code_snippet_one():
validate,
)
from ._version_info import VersionInfo


__version__ = "20.2.0.dev0"
"""
)

Expand Down Expand Up @@ -81,12 +80,6 @@ def test_attrs_code_snippet_three():

from __future__ import absolute_import, division, print_function

import re

from ._make import _AndValidator, and_, attrib, attrs
from .exceptions import NotCallableError


__all__ = [
"and_",
"deep_iterable",
Expand All @@ -98,5 +91,10 @@ def test_attrs_code_snippet_three():
"optional",
"provides",
]

import re

from ._make import _AndValidator, and_, attrib, attrs
from .exceptions import NotCallableError
'''
)
26 changes: 13 additions & 13 deletions tests/unit/profiles/test_open_stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,19 @@ def test_open_stack_code_snippet_three():
# License for the specific language governing permissions and limitations
# under the License.

__all__ = [
'init',
'cleanup',
'set_defaults',
'add_extra_exmods',
'clear_extra_exmods',
'get_allowed_exmods',
'RequestContextSerializer',
'get_client',
'get_server',
'get_notifier',
]

import functools

from oslo_log import log as logging
Expand All @@ -115,19 +128,6 @@ def test_open_stack_code_snippet_three():
import nova.exception
from nova.i18n import _

__all__ = [
'init',
'cleanup',
'set_defaults',
'add_extra_exmods',
'clear_extra_exmods',
'get_allowed_exmods',
'RequestContextSerializer',
'get_client',
'get_server',
'get_notifier',
]

profiler = importutils.try_import("osprofiler.profiler")
""",
known_first_party=["nova"],
Expand Down
74 changes: 73 additions & 1 deletion tests/unit/test_isort.py
Original file line number Diff line number Diff line change
Expand Up @@ -3730,7 +3730,6 @@ def test_new_lines_are_preserved() -> None:


def test_forced_separate_is_deterministic_issue_774(tmpdir) -> None:

config_file = tmpdir.join("setup.cfg")
config_file.write(
"[isort]\n"
Expand Down Expand Up @@ -5591,3 +5590,76 @@ def test_infinite_loop_in_unmatched_parenthesis() -> None:

# ensure other cases are handled correctly
assert isort.code(test_input) == "from os import path, walk\n"


def test_dunders() -> None:
"""Test to ensure dunder imports are in the correct location."""
test_input = """from __future__ import division

import os
import sys

__all__ = ["dla"]
__version__ = '0.1'
__author__ = 'someone'
"""

expected_output = """from __future__ import division

__all__ = ["dla"]
__version__ = '0.1'
__author__ = 'someone'

import os
import sys
"""
assert isort.code(test_input) == expected_output


def test_multiline_dunders() -> None:
"""Test to ensure isort correctly handles multiline dunders"""
test_input = """from __future__ import division

import os
import sys

__all__ = [
"one",
"two",
]
__version__ = '0.1'
__author__ = '''
first name
last name
'''
"""

expected_output = """from __future__ import division

__all__ = [
"one",
"two",
]
__version__ = '0.1'
__author__ = '''
first name
last name
'''

import os
import sys
"""
assert isort.code(test_input) == expected_output


def test_dunders_needs_import() -> None:
"""Test to ensure dunder definitions that need imports are not moved."""
test_input = """from importlib import metadata

__version__ = metadata.version("isort")
__all__ = ["dla"]
__author__ = 'someone'
"""

expected_output = test_input
assert isort.code(test_input) == expected_output
1 change: 1 addition & 0 deletions tests/unit/test_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def test_file_contents():
_,
_,
_,
_,
change_count,
original_line_count,
_,
Expand Down