"""Tests for scripts/enforce_kwargs_spacing.py rewrite rules (AST-preserving, idempotent).""" from __future__ import annotations import ast import sys from pathlib import Path import pytest _SCRIPTS = str(Path(__file__).resolve().parent.parent / "scripts") if _SCRIPTS not in sys.path: sys.path.insert(0, _SCRIPTS) from enforce_kwargs_spacing import ( # noqa: E402 collapse_short_asserts, enforce_spacing, merge_adjacent_string_literals, normalize_def_trailing_comma, remove_blank_after_short_import, ) # (name, source) pairs where the blank after the import block MUST be removed. _MUST_CHANGE = { "try_except_import": ( "def f():\n" " try:\n" " import torch\n" "\n" " return torch.inference_mode\n" " except Exception:\n" " from contextlib import nullcontext\n" "\n" " return nullcontext\n" ), "if_from_import": ( "def g():\n" " if cond:\n" " from . import locators\n" "\n" " regions = locators.regions()\n" ), "multiple_consecutive_imports": ("def f():\n import a\n import b\n\n return a, b\n"), "type_checking_block": ( "def f():\n" " if TYPE_CHECKING:\n" " import x\n" "\n" " y = x\n" " return y\n" ), "with_block": ("def f():\n with ctx():\n import a\n\n return a.run()\n"), } # Sources that MUST be left byte-for-byte unchanged. _MUST_NOT_CHANGE = { "module_level": 'import os\n\nVALUE = os.environ.get("V")\n', "large_suite": ( "def f():\n" " import a\n" "\n" " x = a.load()\n" " y = transform(x)\n" " return y\n" ), "comment_between": ("def f():\n import a\n\n # keep separated\n return a.value\n"), "import_is_last_stmt": "def f():\n if cond:\n import a\n\n", "no_blank_already": "def f():\n import a\n return a\n", } @pytest.mark.parametrize("name", sorted(_MUST_CHANGE)) def test_blank_removed_for_small_import_block(name): src = _MUST_CHANGE[name] out, changed = remove_blank_after_short_import(src) assert changed is True assert out != src # Import and following statement now adjacent. assert "\n\n" not in out or out.count("\n\n") < src.count("\n\n") assert ast.dump(ast.parse(out)) == ast.dump(ast.parse(src)) out2, changed2 = remove_blank_after_short_import(out) assert out2 == out and changed2 is False @pytest.mark.parametrize("name", sorted(_MUST_NOT_CHANGE)) def test_blank_preserved_when_not_applicable(name): src = _MUST_NOT_CHANGE[name] out, changed = remove_blank_after_short_import(src) assert changed is False assert out == src def test_exact_output_try_block(): src = ( "def f():\n" " try:\n" " import torch\n" "\n" " return torch.inference_mode\n" " except Exception:\n" " from contextlib import nullcontext\n" "\n" " return nullcontext\n" ) expected = ( "def f():\n" " try:\n" " import torch\n" " return torch.inference_mode\n" " except Exception:\n" " from contextlib import nullcontext\n" " return nullcontext\n" ) out, changed = remove_blank_after_short_import(src) assert changed is True assert out == expected def test_exact_output_multiple_consecutive_imports(): # Only the blank after the LAST import in a run is dropped; both imports kept. src = "def f():\n import a\n import b\n\n return a, b\n" expected = "def f():\n import a\n import b\n return a, b\n" out, changed = remove_blank_after_short_import(src) assert changed is True assert out == expected def test_multiple_blank_lines_in_gap_all_removed(): src = "def f():\n import a\n\n\n return a\n" expected = "def f():\n import a\n return a\n" out, changed = remove_blank_after_short_import(src) assert changed is True assert out == expected out2, changed2 = remove_blank_after_short_import(out) assert out2 == out and changed2 is False def test_multiline_import_internal_blank_preserved(): # A blank inside a parenthesized import is part of the import, not the gap. src = ( "def g():\n" " from mod import (\n" " a,\n" "\n" " b,\n" " )\n" "\n" " return a, b\n" ) expected = ( "def g():\n" " from mod import (\n" " a,\n" "\n" " b,\n" " )\n" " return a, b\n" ) out, changed = remove_blank_after_short_import(src) assert changed is True assert out == expected assert ast.dump(ast.parse(out)) == ast.dump(ast.parse(src)) out2, changed2 = remove_blank_after_short_import(out) assert out2 == out and changed2 is False def test_syntax_error_is_left_alone(): src = "def f(:\n import a\n\n return a\n" out, changed = remove_blank_after_short_import(src) assert changed is False assert out == src def test_enforce_spacing_pads_kwargs(): src = "f(a=1, b = 2)\n" out, changed = enforce_spacing(src) assert changed is True assert "a = 1" in out and "b = 2" in out def test_enforce_spacing_noop_when_already_spaced(): src = "f(a = 1, b = 2)\n" out, changed = enforce_spacing(src) assert changed is False assert out == src # ── Rule D: def one-per-line iff >= 3 params AND a default ────────────────── # add comma -> force one-per-line; strip comma -> stay collapsible. # Comma must be ADDED: >= 3 params, has a default, no trailing comma yet. _DEF_ADD = { "three_with_default": "def f(a, b, c=1):\n return a\n", "four_with_default": "def f(a, b, c, d=1):\n return a\n", "kwonly_default": "def f(a, b, *, c=1):\n return a\n", # 3 real params, kw default "continuation_default": "def f(\n a, b, c=1\n):\n return a\n", "starred_with_default": "def f(a, b, *args, c=1):\n return a\n", # 4 params } # Comma must be STRIPPED: NOT (>=3 params and default), but a trailing comma exists. _DEF_STRIP = { "three_no_default_multiline": "def f(\n a,\n b,\n c,\n):\n return a\n", "four_no_default_multiline": "def f(\n a,\n b,\n c,\n d,\n):\n return a\n", "two_with_default": "def f(\n a,\n b=1,\n):\n return a\n", # < 3 params -> one line "single_arg": "def f(\n a,\n):\n return a\n", } # Left byte-for-byte unchanged. _DEF_NOCHANGE = { "three_no_default_oneline": "def f(a, b, c):\n return a\n", "two_with_default_oneline": "def f(a, b=1):\n return a\n", # < 3 -> one line, no comma "noparams": "def f():\n return 1\n", "call_site": "x = foo(\n a,\n b,\n c,\n d,\n)\n", "nested_default_call": "def f(a=g(1, 2,)):\n return a\n", # 1 param, no def comma "three_default_already_comma": "def f(\n a,\n b,\n c=1,\n):\n return a\n", } @pytest.mark.parametrize("name", sorted(_DEF_ADD)) def test_def_comma_added(name): src = _DEF_ADD[name] out, changed = normalize_def_trailing_comma(src) assert changed is True assert ast.dump(ast.parse(out)) == ast.dump(ast.parse(src)) assert out.count(",") == src.count(",") + 1 out2, changed2 = normalize_def_trailing_comma(out) assert out2 == out and changed2 is False @pytest.mark.parametrize("name", sorted(_DEF_STRIP)) def test_def_comma_stripped(name): src = _DEF_STRIP[name] out, changed = normalize_def_trailing_comma(src) assert changed is True assert ast.dump(ast.parse(out)) == ast.dump(ast.parse(src)) assert out.count(",") == src.count(",") - 1 out2, changed2 = normalize_def_trailing_comma(out) assert out2 == out and changed2 is False @pytest.mark.parametrize("name", sorted(_DEF_NOCHANGE)) def test_def_comma_unchanged(name): src = _DEF_NOCHANGE[name] out, changed = normalize_def_trailing_comma(src) assert changed is False assert out == src def test_def_comma_exact_output_strip_and_add(): # >= 3 params + default -> add comma (force one-per-line) assert normalize_def_trailing_comma("def f(a, b, c=1):\n return a\n")[0] == ( "def f(a, b, c=1,):\n return a\n" ) # 3 params, no default -> strip comma (collapsible) assert ( normalize_def_trailing_comma("def f(\n a,\n b,\n c,\n):\n return a\n")[0] == "def f(\n a,\n b,\n c\n):\n return a\n" ) # ── Rule C: merge adjacent same-line string literals ─────────────────────── @pytest.mark.parametrize( "src,expected", [ ('x = "ab" "cd"\n', 'x = "abcd"\n'), ('d = "newly-" "added dep."\n', 'd = "newly-added dep."\n'), ('m = ("a. " "b.")\n', 'm = ("a. b.")\n'), ('x = r"a\\n" r"b"\n', 'x = r"a\\nb"\n'), ('x = "a\\"q" "b"\n', 'x = "a\\"qb"\n'), # f + plain folds into one f-string (plain braces escaped). ('x = f"a" "b"\n', 'x = f"ab"\n'), ( 'd = (f"{pkg}@{ver} is on the " "BLOCKED list")\n', 'd = (f"{pkg}@{ver} is on the BLOCKED list")\n', ), ('x = f"a{z}" "{lit}"\n', 'x = f"a{z}{{lit}}"\n'), ('m = "plain " f"then {y}"\n', 'm = f"plain then {y}"\n'), # plain + f ], ) def test_merge_adjacent_strings(src, expected): out, changed = merge_adjacent_string_literals(src) assert changed is True assert out == expected assert ast.dump(ast.parse(out)) == ast.dump(ast.parse(src)) out2, changed2 = merge_adjacent_string_literals(out) assert out2 == out and changed2 is False @pytest.mark.parametrize( "src", [ 'x = "ab"\n', # single literal "x = \"ab\" 'cd'\n", # mixed quote style 'x = b"a" b"b"\n', # bytes: left side-by-side by request 'x = rb"a" rb"b"\n', # raw-bytes: also left alone 'm = f"a {x} " f"after {y}"\n', # pure f + f: left side-by-side 'x = rf"a{z}" "b"\n', # raw f-string: brace/backslash too subtle -> skip 'x = f"a{z}" "\\N{BULLET}"\n', # named escape: AST guard rejects the fold 'x = (\n "a"\n "b"\n)\n', # different lines, not merged ], ) def test_merge_adjacent_strings_skips(src): out, changed = merge_adjacent_string_literals(src) assert changed is False assert out == src def test_fstring_fold_skipped_when_statement_would_not_collapse(): # Folding a long f + plain assert message can't fit on one line, so leave it. src = ( "def f():\n" " assert some_condition_holds_here, (\n" ' f"a fairly detailed message about {value} explaining " "why this failed badly"\n' " )\n" ) out, changed = merge_adjacent_string_literals(src) assert changed is False assert out == src def test_fstring_fold_applied_when_statement_collapses(): # A multi-line f + plain that fits on one line after folding is folded. src = "def f():\n raise ValueError(\n" ' f"bad {x}: " "try again"\n' " )\n" out, changed = merge_adjacent_string_literals(src) assert changed is True assert 'f"bad {x}: try again"' in out assert ast.dump(ast.parse(out)) == ast.dump(ast.parse(src)) def test_fstring_fold_applied_inside_large_multiline_call(): # The fit guard only restricts asserts; an f + plain arg in a big call folds. src = ( "findings.append(\n" " Finding(\n" " path=str(path),\n" " package=key,\n" ' detail=(f"{name}@{ver} is on the " "BLOCKED list"),\n' " )\n" ")\n" ) out, changed = merge_adjacent_string_literals(src) assert changed is True assert 'detail=(f"{name}@{ver} is on the BLOCKED list")' in out assert ast.dump(ast.parse(out)) == ast.dump(ast.parse(src)) # ── collapse_short_asserts: strip the magic comma holding a short assert open ── # Strips the trailing comma so ruff joins the assert onto one line; AST unchanged. @pytest.mark.parametrize( "name,src", [ ( "dict_eq", 'def t():\n assert got == {\n "a": 1,\n "b": 2,\n }\n', ), ( "list_eq", 'def t():\n assert xs == [\n "a",\n "b",\n "c",\n ]\n', ), ( "membership", 'def t():\n assert {\n "type": "x",\n "name": "y",\n } in tools\n', ), ( "tuple_message", "def t():\n assert cond, (\n base,\n headers,\n )\n", ), ( "call_args", "def t():\n assert eq(\n a,\n b,\n )\n", ), ], ) def test_collapse_short_assert_strips_trailing_comma(name, src): out, changed = collapse_short_asserts(src) assert changed is True # Magic trailing comma is gone, so ruff joins it on the next pass. assert out.count(",") == src.count(",") - 1 assert ast.dump(ast.parse(out)) == ast.dump(ast.parse(src)) out2, changed2 = collapse_short_asserts(out) assert out2 == out and changed2 is False @pytest.mark.parametrize( "name,src", [ # one-element tuple message: stripping (only,) -> (only) changes meaning. ("one_tuple_message", "def t():\n assert cond, (\n only,\n )\n"), # a comment inside keeps ruff multi-line, so collapsing would oscillate. ( "comment_inside", 'def t():\n assert x == {\n "a": 1, # keep\n "b": 2,\n }\n', ), # genuinely long: would not fit on one line, leave expanded. ( "too_long", "def t():\n assert some_really_long_left_operand_name_here == {\n" ' "alpha": 11111111,\n "beta": 22222222,\n' ' "gamma": 33333333,\n "delta": 44444444,\n }\n', ), # already one line: nothing to do. ("one_line", 'def t():\n assert got == {"a": 1, "b": 2}\n'), ], ) def test_collapse_short_assert_left_alone(name, src): out, changed = collapse_short_asserts(src) assert changed is False assert out == src