Skip to content

Commit 148efe4

Browse files
Warn when target version exceeds runtime Python version (#4983)
1 parent 5566a35 commit 148efe4

File tree

3 files changed

+90
-1
lines changed

3 files changed

+90
-1
lines changed

CHANGES.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@
4949

5050
<!-- Changes to Black's terminal output and error messages -->
5151

52+
- Emit a clear warning when the target Python version is newer than the running Python
53+
version, since AST safety checks cannot parse newer syntax. Also replace the
54+
misleading "INTERNAL ERROR" message with an actionable error explaining the version
55+
mismatch (#4983)
56+
5257
### _Blackd_
5358

5459
<!-- Changes to blackd -->

src/black/__init__.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,27 @@ def target_version_option_callback(
202202
return [TargetVersion[val.upper()] for val in v]
203203

204204

205+
def _target_versions_exceed_runtime(
206+
target_versions: set[TargetVersion],
207+
) -> bool:
208+
if not target_versions:
209+
return False
210+
max_target_minor = max(tv.value for tv in target_versions)
211+
return max_target_minor > sys.version_info[1]
212+
213+
214+
def _version_mismatch_message(target_versions: set[TargetVersion]) -> str:
215+
max_target = max(target_versions, key=lambda tv: tv.value)
216+
runtime = f"{sys.version_info[0]}.{sys.version_info[1]}"
217+
return (
218+
f"Python {runtime} cannot parse code formatted for"
219+
f" {max_target.pretty()}. To fix this: run Black with"
220+
f" {max_target.pretty()}, set --target-version to"
221+
f" py3{sys.version_info[1]}, or use --fast to skip the safety"
222+
" check."
223+
)
224+
225+
205226
def enable_unstable_feature_callback(
206227
c: click.Context, p: click.Option | click.Parameter, v: tuple[str, ...]
207228
) -> list[Preview]:
@@ -642,6 +663,14 @@ def main(
642663
enabled_features=set(enable_unstable_feature),
643664
)
644665

666+
if not fast and _target_versions_exceed_runtime(versions):
667+
err(
668+
f"Warning: {_version_mismatch_message(versions)} Black's safety"
669+
" check verifies equivalence by parsing the AST, which fails"
670+
" when the running Python is older than the target version.",
671+
fg="yellow",
672+
)
673+
645674
lines: list[tuple[int, int]] = []
646675
if line_ranges:
647676
if ipynb:
@@ -1055,7 +1084,15 @@ def check_stability_and_equivalence(
10551084
equivalent, or if a second pass of the formatter would format the
10561085
content differently.
10571086
"""
1058-
assert_equivalent(src_contents, dst_contents)
1087+
try:
1088+
assert_equivalent(src_contents, dst_contents)
1089+
except ASTSafetyError:
1090+
if _target_versions_exceed_runtime(mode.target_versions):
1091+
raise ASTSafetyError(
1092+
"failed to verify equivalence of the formatted output:"
1093+
f" {_version_mismatch_message(mode.target_versions)}"
1094+
) from None
1095+
raise
10591096
assert_stable(src_contents, dst_contents, mode=mode, lines=lines)
10601097

10611098

tests/test_black.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3174,6 +3174,53 @@ def test_equivalency_ast_parse_failure_includes_error(self) -> None:
31743174
err.match("invalid character")
31753175
err.match(r"\(<unknown>, line 1\)")
31763176

3177+
def test_target_version_exceeds_runtime_warning(self) -> None:
3178+
max_target = max(TargetVersion, key=lambda tv: tv.value)
3179+
if sys.version_info[1] >= max_target.value:
3180+
pytest.skip("no target version higher than runtime available")
3181+
target_name = f"py3{sys.version_info[1] + 1}"
3182+
code = "x = 1\n"
3183+
args = ["--target-version", target_name, "--code", code]
3184+
result = CliRunner().invoke(black.main, args)
3185+
stderr = result.stderr_bytes.decode() if result.stderr_bytes else ""
3186+
assert "Warning:" in stderr
3187+
3188+
def test_target_version_exceeds_runtime_no_warning_with_fast(self) -> None:
3189+
max_target = max(TargetVersion, key=lambda tv: tv.value)
3190+
if sys.version_info[1] >= max_target.value:
3191+
pytest.skip("no target version higher than runtime available")
3192+
target_name = f"py3{sys.version_info[1] + 1}"
3193+
code = "x = 1\n"
3194+
args = ["--fast", "--target-version", target_name, "--code", code]
3195+
result = CliRunner().invoke(black.main, args)
3196+
stderr = result.stderr_bytes.decode() if result.stderr_bytes else ""
3197+
assert "Warning:" not in stderr
3198+
3199+
def test_target_version_at_runtime_no_warning(self) -> None:
3200+
current_minor = sys.version_info[1]
3201+
target_name = f"py3{current_minor}"
3202+
code = "x = 1\n"
3203+
args = ["--target-version", target_name, "--code", code]
3204+
result = CliRunner().invoke(black.main, args)
3205+
stderr = result.stderr_bytes.decode() if result.stderr_bytes else ""
3206+
assert "Warning:" not in stderr
3207+
3208+
@pytest.mark.incompatible_with_mypyc
3209+
def test_target_version_exceeds_runtime_clear_error_message(self) -> None:
3210+
max_target = max(TargetVersion, key=lambda tv: tv.value)
3211+
if sys.version_info[1] >= max_target.value:
3212+
pytest.skip("no target version higher than runtime available")
3213+
future_target = TargetVersion[f"PY3{sys.version_info[1] + 1}"]
3214+
mode = Mode(target_versions={future_target})
3215+
with patch.object(
3216+
black,
3217+
"assert_equivalent",
3218+
side_effect=ASTSafetyError("mocked parse failure"),
3219+
):
3220+
with pytest.raises(ASTSafetyError) as exc_info:
3221+
black.check_stability_and_equivalence("x = 1\n", "x = 1\n", mode=mode)
3222+
assert "INTERNAL ERROR" not in str(exc_info.value)
3223+
31773224

31783225
try:
31793226
with open(black.__file__, encoding="utf-8") as _bf:

0 commit comments

Comments
 (0)