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

Fix for #4264: --line-ranges formats entire file when ranges are at EOF #4273

Merged
merged 3 commits into from
Mar 15, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Expand Up @@ -15,6 +15,8 @@
of Black would incorrectly format the contents of certain unusual f-strings containing
nested strings with the same quote type. Now, Black will crash on such strings until
support for the new f-string syntax is implemented. (#4270)
- Fixed a bug where line-ranges exceeding the last code line would not work as expected
(#4273)

### Preview style

Expand Down
11 changes: 10 additions & 1 deletion src/black/__init__.py
Expand Up @@ -84,7 +84,12 @@
parse_ast,
stringify_ast,
)
from black.ranges import adjusted_lines, convert_unchanged_lines, parse_line_ranges
from black.ranges import (
adjusted_lines,
convert_unchanged_lines,
parse_line_ranges,
sanitized_lines,
)
from black.report import Changed, NothingChanged, Report
from black.trans import iter_fexpr_spans
from blib2to3.pgen2 import token
Expand Down Expand Up @@ -1220,6 +1225,10 @@ def f(
hey

"""
if lines:
lines = sanitized_lines(lines, src_contents)
if not lines:
return src_contents # Nothing to format
dst_contents = _format_str_once(src_contents, mode=mode, lines=lines)
# Forced second pass to work around optional trailing commas (becoming
# forced trailing commas on pass 2) interacting differently with optional
Expand Down
26 changes: 26 additions & 0 deletions src/black/ranges.py
Expand Up @@ -45,6 +45,32 @@ def is_valid_line_range(lines: Tuple[int, int]) -> bool:
return not lines or lines[0] <= lines[1]


def sanitized_lines(
lines: Collection[Tuple[int, int]], src_contents: str
) -> Collection[Tuple[int, int]]:
"""Returns the valid line ranges for the given source.

This removes ranges that are entirely outside the valid lines.

Other ranges are normalized so that the start values are at least 1 and the
end values are at most the (1-based) index of the last source line.
"""
if not src_contents:
return []
good_lines = []
src_line_count = len(src_contents.splitlines())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not too strong an opinion, it can be more efficient to do a count("\n") but then you also need to add 1 when it doesn't end with a new line.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, that's really quite a difference

$ python -m timeit -n 10000 -s "f = open('src/black/__init__.py'); src=f.read(); f.close()" "len(src.splitlines())"
10000 loops, best of 5: 171 usec per loop
$ python -m timeit -n 10000 -s "f = open('src/black/__init__.py'); src=f.read(); f.close()" "src.count('\n')"  
10000 loops, best of 5: 36.6 usec per loop

I resisted the temptation to write src_contents.count("\n") + src_contents[-1] != "\n" 😄

for start, end in lines:
if start > src_line_count:
continue
# line-ranges are 1-based
start = max(start, 1)
if end < start:
continue
end = min(end, src_line_count)
good_lines.append((start, end))
return good_lines


def adjusted_lines(
lines: Collection[Tuple[int, int]],
original_source: str,
Expand Down
36 changes: 36 additions & 0 deletions tests/data/cases/line_ranges_exceeding_end.py
@@ -0,0 +1,36 @@
# flags: --line-ranges=6-1000
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
def foo1(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo2(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo3(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo4(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass

# output
# flags: --line-ranges=6-1000
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines.
def foo1(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo2(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo3(
parameter_1,
parameter_2,
parameter_3,
parameter_4,
parameter_5,
parameter_6,
parameter_7,
):
pass


def foo4(
parameter_1,
parameter_2,
parameter_3,
parameter_4,
parameter_5,
parameter_6,
parameter_7,
):
pass
41 changes: 41 additions & 0 deletions tests/data/cases/line_ranges_outside_source.py
@@ -0,0 +1,41 @@
# flags: --line-ranges=5000-6000
# NOTE: If you need to modify this file, pay special attention to the --line-ranges=
# flag above as it's formatting specifically these lines, in this case none.
def foo1(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo2(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo3(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass
def foo4(parameter_1, parameter_2, parameter_3, parameter_4, parameter_5, parameter_6, parameter_7): pass

# Adding some unformated code covering a wide range of syntaxes.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would simply remove the lines below, as this test case just need to verify a completely out-of-range input doesn't format


if True:
# Incorrectly indented prefix comments.
pass

import typing
from typing import (
Any ,
)
class MyClass( object): # Trailing comment with extra leading space.
#NOTE: The following indentation is incorrect:
@decor( 1 * 3 )
def my_func( arg):
pass

try: # Trailing comment with extra leading space.
for i in range(10): # Trailing comment with extra leading space.
while condition:
if something:
then_something( )
elif something_else:
then_something_else( )
except ValueError as e:
unformatted( )
finally:
unformatted( )

async def test_async_unformatted( ): # Trailing comment with extra leading space.
async for i in some_iter( unformatted ): # Trailing comment with extra leading space.
await asyncio.sleep( 1 )
async with some_context( unformatted ):
print( "unformatted" )
59 changes: 58 additions & 1 deletion tests/test_ranges.py
Expand Up @@ -4,7 +4,7 @@

import pytest

from black.ranges import adjusted_lines
from black.ranges import adjusted_lines, sanitized_lines


@pytest.mark.parametrize(
Expand Down Expand Up @@ -183,3 +183,60 @@ def test_diffs(lines: List[Tuple[int, int]], adjusted: List[Tuple[int, int]]) ->
12. # last line changed
"""
assert adjusted == adjusted_lines(lines, original_source, modified_source)


@pytest.mark.parametrize(
"lines,sanitized",
[
(
[(1, 4)],
[(1, 4)],
),
(
[(2, 3)],
[(2, 3)],
),
(
[(2, 10)],
[(2, 4)],
),
(
[(0, 3)],
[(1, 3)],
),
(
[(0, 10)],
[(1, 4)],
),
(
[(-2, 3)],
[(1, 3)],
),
(
[(0, 0)],
[],
),
(
[(-2, -1)],
[],
),
(
[(-1, 0)],
[],
),
(
[(3, 1), (1, 3), (5, 6)],
[(1, 3)],
),
],
)
def test_sanitize(
lines: List[Tuple[int, int]], sanitized: List[Tuple[int, int]]
) -> None:
source = """\
1. import re
2. def func(arg1,
3. arg2, arg3):
4. pass
"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also add a case for source not ending with a newline?

assert sanitized == sanitized_lines(lines, source)