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

Improve AST safety parsing error message #2304

Merged
merged 9 commits into from Jul 13, 2021
1 change: 1 addition & 0 deletions CHANGES.md
Expand Up @@ -8,6 +8,7 @@
- Fixed option usage when using the `--code` flag (#2259)
- Do not call `uvloop.install()` when _Black_ is used as a library (#2303)
- Added `--required-version` option to require a specific version to be running (#2300)
- Provide a more useful error when parsing fails during AST safety checks (#2304)

## 21.5b2

Expand Down
49 changes: 29 additions & 20 deletions src/black/parsing.py
Expand Up @@ -3,7 +3,7 @@
"""
import ast
import sys
from typing import Iterable, Iterator, List, Set, Union
from typing import Iterable, Iterator, List, Set, Union, Tuple

# lib2to3 fork
from blib2to3.pytree import Node, Leaf
Expand Down Expand Up @@ -106,28 +106,37 @@ def lib2to3_unparse(node: Node) -> str:
return code


def parse_ast(src: str) -> Union[ast.AST, ast3.AST, ast27.AST]:
def parse_single_version(
src: str, version: Tuple[int, int]
) -> Union[ast.AST, ast3.AST, ast27.AST]:
filename = "<unknown>"
if (3, 8) <= version < (4, 0):
return ast.parse(src, filename, feature_version=version)
elif version >= (3, 6):
felix-hilden marked this conversation as resolved.
Show resolved Hide resolved
return ast3.parse(src, filename, feature_version=version[1])
elif version == (2, 7):
return ast27.parse(src)
raise AssertionError("INTERNAL ERROR: Tried parsing unsupported Python version!")


def parse_ast(src: str) -> Union[ast.AST, ast3.AST, ast27.AST]:
versions = [(3, 6), (3, 7)]
if sys.version_info >= (3, 8):
# TODO: support Python 4+ ;)
for minor_version in range(sys.version_info[1], 4, -1):
try:
return ast.parse(src, filename, feature_version=(3, minor_version))
except SyntaxError:
continue
else:
for feature_version in (7, 6):
try:
return ast3.parse(src, filename, feature_version=feature_version)
except SyntaxError:
continue
if ast27.__name__ == "ast":
raise SyntaxError(
"The requested source code has invalid Python 3 syntax.\n"
"If you are trying to format Python 2 files please reinstall Black"
" with the 'python2' extra: `python3 -m pip install black[python2]`."
)
return ast27.parse(src)
versions.extend([(3, minor) for minor in range(8, sys.version_info[1] + 1)])

if ast27.__name__ != "ast":
versions.append((2, 7))

first_error = ""
for version in sorted(versions, reverse=True):
try:
return parse_single_version(src, version)
except SyntaxError as e:
if not first_error:
first_error = str(e)

raise SyntaxError(first_error)


def stringify_ast(
Expand Down
31 changes: 0 additions & 31 deletions tests/test_black.py
Expand Up @@ -448,37 +448,6 @@ def test_skip_magic_trailing_comma(self) -> None:
)
self.assertEqual(expected, actual, msg)

@pytest.mark.no_python2
def test_python2_should_fail_without_optional_install(self) -> None:
if sys.version_info < (3, 8):
self.skipTest(
"Python 3.6 and 3.7 will install typed-ast to work and as such will be"
" able to parse Python 2 syntax without explicitly specifying the"
" python2 extra"
)

source = "x = 1234l"
tmp_file = Path(black.dump_to_file(source))
try:
runner = BlackRunner()
result = runner.invoke(black.main, [str(tmp_file)])
self.assertEqual(result.exit_code, 123)
finally:
os.unlink(tmp_file)
actual = (
result.stderr_bytes.decode()
.replace("\n", "")
.replace("\\n", "")
.replace("\\r", "")
.replace("\r", "")
)
msg = (
"The requested source code has invalid Python 3 syntax."
"If you are trying to format Python 2 files please reinstall Black"
" with the 'python2' extra: `python3 -m pip install black[python2]`."
)
self.assertIn(msg, actual)

@pytest.mark.python2
@patch("black.dump_to_file", dump_to_stderr)
def test_python2_print_function(self) -> None:
Expand Down