TheAlgorithms/Python · #15570

Add scripts/find_and_fix_pep798.py

cclauss · merged Oct 13, 20263 files · 394 + / 0 −
DIRECTORY.md3 + / 0 −
@@ -867,6 +867,7 @@   * [Josephus Problem](maths/josephus_problem.py)   * [Juggler Sequence](maths/juggler_sequence.py)   * [Karatsuba](maths/karatsuba.py)+  * [Keith Number](maths/keith_number.py)   * [Kth Lexicographic Permutation](maths/kth_lexicographic_permutation.py)   * [Laplace Transformation](maths/laplace_transformation.py)   * [Largest Of Very Large Numbers](maths/largest_of_very_large_numbers.py)@@ -1492,6 +1493,7 @@   * [Non Preemptive Shortest Job First](scheduling/non_preemptive_shortest_job_first.py)   * [Round Robin](scheduling/round_robin.py)   * [Shortest Job First](scheduling/shortest_job_first.py)+  * [Weighted Interval Scheduling](scheduling/weighted_interval_scheduling.py)  ## [Searches](searches)   * [Binary Search](searches/binary_search.py)@@ -1642,6 +1644,7 @@   * [Z Function](strings/z_function.py)  ## [Tests](tests)+  * [Test Find And Fix Pep798](tests/test_find_and_fix_pep798.py)   * [Test Sorts](tests/test_sorts.py)  ## [Web Programming](web_programming)
scripts/find_and_fix_pep798.pyadded245 + / 0 −
@@ -0,0 +1,245 @@+#!/usr/bin/env -S uv run --script+#+# /// script+# requires-python = ">=3.13"+# dependencies = []+# ///++"""+Find (and optionally fix) opportunities to use PEP 798 in Python source files.++PEP 798 allows unpacking inside comprehensions and generator expressions:+https://peps.python.org/pep-0798/++The following flattening patterns are detected:++    [y for x in xs for y in x]                      ->  [*x for x in xs]+    {y for x in xs for y in x}                      ->  {*x for x in xs}+    (y for x in xs for y in x)                      ->  (*x for x in xs)+    {k: v for d in ds for k, v in d.items()}        ->  {**d for d in ds}++Only the simplest, always-equivalent forms are rewritten: exactly two ``for``+clauses, no ``async``, and no ``if`` filter on the inner clause.  Nested+candidates are fixed innermost first; re-run the script to catch any leftovers.+The rewritten source uses Python 3.15+ syntax, so fixed files need 3.15+.++Usage:+    scripts/find_and_fix_pep798.py [--fix] PATH [PATH ...]++Without ``--fix`` the script only reports and exits with status 1 if any+opportunities were found.+"""++import argparse+import ast+import sys+from collections.abc import Iterable, Iterator+from dataclasses import dataclass+from pathlib import Path++Comprehension = ast.ListComp | ast.SetComp | ast.GeneratorExp | ast.DictComp++# Nodes that need parentheses after ``*`` or ``**`` (they bind looser than ``|``).+_NEEDS_PARENS_AFTER_STAR = (+    ast.BoolOp,+    ast.Compare,+    ast.IfExp,+    ast.Lambda,+    ast.NamedExpr,+    ast.UnaryOp,+)+# Nodes that need parentheses inside a ``for ... in`` / ``if`` comprehension clause.+_NEEDS_PARENS_IN_CLAUSE = (ast.IfExp, ast.Lambda, ast.NamedExpr, ast.Yield)+++@dataclass(frozen=True)+class Finding:+    """A PEP 798 opportunity: a source span and its replacement text."""++    line: int+    start: int+    end: int+    old: str+    new: str+++def _is_name(node: ast.expr, name: str) -> bool:+    return isinstance(node, ast.Name) and node.id == name+++def _is_flattening(node: Comprehension) -> bool:+    """+    Return True if the comprehension is a simple two-level flattening.++    >>> def check(code: str) -> bool:+    ...     return _is_flattening(ast.parse(code, mode="eval").body)+    >>> check("[y for x in xs for y in x]")+    True+    >>> check("[y for x in xs if x for y in x]")+    True+    >>> check("[y for x in xs for y in x if y]")+    False+    >>> check("[y + 1 for x in xs for y in x]")+    False+    >>> check("[y for x in xs]")+    False+    >>> check("{k: v for d in ds for k, v in d.items()}")+    True+    >>> check("{k: v for d in ds for k, v in d.items() if v}")+    False+    >>> check("{k: v for d in ds for v, k in d.items()}")+    False+    >>> check("{k: v for d in ds for k, v in d.items(1)}")+    False+    """+    if len(node.generators) != 2:+        return False+    outer, inner = node.generators+    if outer.is_async or inner.is_async or inner.ifs:+        return False+    if isinstance(node, ast.DictComp):+        match inner.target, node.key, node.value, inner.iter:+            case (+                ast.Tuple(elts=[ast.Name(id=a), ast.Name(id=b)]),+                ast.Name(id=key),+                ast.Name(id=value),+                ast.Call(func=ast.Attribute(attr="items"), args=[], keywords=[]),+            ):+                return a != b and (a, b) == (key, value)+        return False+    return isinstance(inner.target, ast.Name) and _is_name(node.elt, inner.target.id)+++class _Offsets:+    """Convert AST (line, UTF-8 byte column) positions to string offsets."""++    def __init__(self, source: str) -> None:+        self.lines = source.splitlines(keepends=True)+        self.line_starts = [0]+        for line in self.lines:+            self.line_starts.append(self.line_starts[-1] + len(line))++    def offset(self, line: int, byte_col: int) -> int:+        text = self.lines[line - 1].encode()[:byte_col].decode()+        return self.line_starts[line - 1] + len(text)+++def _segment(source: str, offsets: _Offsets, node: ast.expr, parens: tuple) -> str:+    start = offsets.offset(node.lineno, node.col_offset)+    end = offsets.offset(node.end_lineno, node.end_col_offset)+    text = source[start:end]+    return f"({text})" if isinstance(node, parens) else text+++def _replacement(source: str, offsets: _Offsets, node: Comprehension) -> str:+    outer, inner = node.generators+    star = "**" if isinstance(node, ast.DictComp) else "*"+    iterable = inner.iter+    if isinstance(node, ast.DictComp):+        iterable = iterable.func.value+    unpacked = _segment(source, offsets, iterable, _NEEDS_PARENS_AFTER_STAR)+    target = _segment(source, offsets, outer.target, ())+    outer_iter = _segment(source, offsets, outer.iter, _NEEDS_PARENS_IN_CLAUSE)+    clauses = [f"for {target} in {outer_iter}"]+    clauses += [+        f"if {_segment(source, offsets, cond, _NEEDS_PARENS_IN_CLAUSE)}"+        for cond in outer.ifs+    ]+    start = offsets.offset(node.lineno, node.col_offset)+    end = offsets.offset(node.end_lineno, node.end_col_offset)+    return f"{source[start]}{star}{unpacked} {' '.join(clauses)}{source[end - 1]}"+++def _span(offsets: _Offsets, node: ast.AST) -> tuple[int, int]:+    return (+        offsets.offset(node.lineno, node.col_offset),+        offsets.offset(node.end_lineno, node.end_col_offset),+    )+++def find_opportunities(source: str) -> list[Finding]:+    """+    Return non-overlapping PEP 798 opportunities, innermost candidates first.++    >>> [f.new for f in find_opportunities("[y for x in xs for y in x]")]+    ['[*x for x in xs]']+    >>> find_opportunities("[y for y in ys]")+    []+    >>> len(find_opportunities("a = [y for x in xs for y in x]\\nb = {*x for x in xs}"))+    1+    """+    tree = ast.parse(source)+    offsets = _Offsets(source)+    candidates = [+        node+        for node in ast.walk(tree)+        if isinstance(node, Comprehension) and _is_flattening(node)+    ]+    # Innermost (smallest) first so that nested candidates keep the inner fix.+    candidates.sort(key=lambda node: _span(offsets, node)[1] - _span(offsets, node)[0])+    taken: list[tuple[int, int]] = []+    findings: list[Finding] = []+    for node in candidates:+        start, end = _span(offsets, node)+        if any(start < t_end and t_start < end for t_start, t_end in taken):+            continue+        taken.append((start, end))+        findings.append(+            Finding(+                line=node.lineno,+                start=start,+                end=end,+                old=source[start:end],+                new=_replacement(source, offsets, node),+            )+        )+    return sorted(findings, key=lambda finding: finding.start)+++def fix_source(source: str) -> str:+    """+    Rewrite every opportunity found by :func:`find_opportunities`.++    >>> print(fix_source("x = [y for ys in yss for y in ys]"))+    x = [*ys for ys in yss]+    >>> print(fix_source("x = {k: v for d in ds if d for k, v in d.items()}"))+    x = {**d for d in ds if d}+    """+    for finding in reversed(find_opportunities(source)):+        source = f"{source[: finding.start]}{finding.new}{source[finding.end :]}"+    return source+++def iter_python_files(paths: Iterable[Path]) -> Iterator[Path]:+    for path in paths:+        if path.is_dir():+            yield from sorted(path.rglob("*.py"))+        else:+            yield path+++def main(argv: list[str] | None = None) -> int:+    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])+    parser.add_argument("paths", nargs="+", type=Path, help="files or directories")+    parser.add_argument("--fix", action="store_true", help="rewrite files in place")+    args = parser.parse_args(argv)++    total = 0+    for path in iter_python_files(args.paths):+        source = path.read_text(encoding="utf-8")+        try:+            findings = find_opportunities(source)+        except SyntaxError as error:+            print(f"{path}: skipped ({error.msg})", file=sys.stderr)+            continue+        for finding in findings:+            print(f"{path}:{finding.line}: {finding.old!r} -> {finding.new!r}")+        total += len(findings)+        if findings and args.fix:+            path.write_text(fix_source(source), encoding="utf-8")+    print(f"{total} PEP 798 opportunit{'y' if total == 1 else 'ies'} found")+    return 0 if args.fix or not total else 1+++if __name__ == "__main__":+    sys.exit(main())
tests/test_find_and_fix_pep798.pyadded146 + / 0 −
@@ -0,0 +1,146 @@+from pathlib import Path++import pytest++from scripts.find_and_fix_pep798 import (+    find_opportunities,+    fix_source,+    iter_python_files,+    main,+)+++@pytest.mark.parametrize(+    ("before", "after"),+    [+        ("[y for x in xs for y in x]", "[*x for x in xs]"),+        ("{y for x in xs for y in x}", "{*x for x in xs}"),+        ("(y for x in xs for y in x)", "(*x for x in xs)"),+        ("sum(y for x in xs for y in x)", "sum(*x for x in xs)"),+        ("{k: v for d in ds for k, v in d.items()}", "{**d for d in ds}"),+        ("[y for x in xs if x for y in x]", "[*x for x in xs if x]"),+        ("[y for x in xs if x if x.a for y in x]", "[*x for x in xs if x if x.a]"),+        ("[y for x, z in xs for y in x.items]", "[*x.items for x, z in xs]"),+        ("[y for x in xs for y in x or ()]", "[*(x or ()) for x in xs]"),+        ("[y for x in (a if b else c) for y in x]", "[*x for x in (a if b else c)]"),+        ("[y for x in xs for y in (x)]", "[*x for x in xs]"),+        ("[y for x in xs for y in f(x)[0]]", "[*f(x)[0] for x in xs]"),+        ("[y for x in xs for y in x][0]", "[*x for x in xs][0]"),+        ("[é for ü in xs for é in ü]", "[*ü for ü in xs]"),+        ("{k: v for d in ds for k, v in (a or b).items()}", "{**(a or b) for d in ds}"),+    ],+)+def test_fixes(before: str, after: str) -> None:+    assert fix_source(before) == after+++@pytest.mark.parametrize(+    "source",+    [+        "[y for y in ys]",+        "[y + 1 for x in xs for y in x]",+        "[z for x in xs for y in x]",+        "[y for x in xs for y in x if y]",+        "[y for x in xs for y in x for z in y]",+        "[x for x in xs for x, y in x]",+        "{k: v for d in ds for k, v in d.items() if v}",+        "{k: v for d in ds for v, k in d.items()}",+        "{k: v for d in ds for k, v in d.items(1)}",+        "{k: v for d in ds for k, v in d}",+        "{k: v for d in ds for k, k in d.items()}",+        "[x for x in xs]",+        "list(itertools.chain.from_iterable(xs))",+    ],+)+def test_no_opportunity(source: str) -> None:+    assert find_opportunities(source) == []+    assert fix_source(source) == source+++def test_async_comprehensions_are_ignored() -> None:+    source = (+        "async def f():\n"+        "    return [y async for x in xs for y in x]\n"+        "async def g():\n"+        "    return [y for x in xs async for y in x]\n"+    )+    assert find_opportunities(source) == []+++def test_find_reports_line_and_text() -> None:+    source = "a = 1\nb = [y for x in xs for y in x]\n"+    (finding,) = find_opportunities(source)+    assert finding.line == 2+    assert finding.old == "[y for x in xs for y in x]"+    assert finding.new == "[*x for x in xs]"+    assert source[finding.start : finding.end] == finding.old+++def test_multiple_findings_in_order() -> None:+    source = (+        "a = [y for x in xs for y in x]\nb = {k: v for d in ds for k, v in d.items()}\n"+    )+    assert fix_source(source) == "a = [*x for x in xs]\nb = {**d for d in ds}\n"+++def test_nested_candidates_fix_innermost_only() -> None:+    source = "[w for v in [y for x in xs for y in x] for w in v]"+    (finding,) = find_opportunities(source)+    assert finding.old == "[y for x in xs for y in x]"+    assert fix_source(source) == "[w for v in [*x for x in xs] for w in v]"+++def test_multiline_comprehension() -> None:+    source = "result = [\n    y\n    for x in xs\n    for y in x\n]\n"+    assert fix_source(source) == "result = [*x for x in xs]\n"+++def test_iter_python_files(tmp_path: Path) -> None:+    (tmp_path / "sub").mkdir()+    first = tmp_path / "a.py"+    second = tmp_path / "sub" / "b.py"+    other = tmp_path / "c.txt"+    for path in (first, second, other):+        path.write_text("")+    assert list(iter_python_files([tmp_path])) == [first, second]+    assert list(iter_python_files([other])) == [other]+++def test_main_reports_without_fixing(+    tmp_path: Path, capsys: pytest.CaptureFixture[str]+) -> None:+    path = tmp_path / "mod.py"+    path.write_text("a = [y for x in xs for y in x]\n")+    assert main([str(path)]) == 1+    output = capsys.readouterr().out+    assert f"{path}:1:" in output+    assert "1 PEP 798 opportunity found" in output+    assert path.read_text() == "a = [y for x in xs for y in x]\n"+++def test_main_fix_rewrites_file(+    tmp_path: Path, capsys: pytest.CaptureFixture[str]+) -> None:+    path = tmp_path / "mod.py"+    path.write_text("a = [y for x in xs for y in x]\nb = {y for x in xs for y in x}\n")+    assert main(["--fix", str(path)]) == 0+    assert path.read_text() == "a = [*x for x in xs]\nb = {*x for x in xs}\n"+    assert "2 PEP 798 opportunities found" in capsys.readouterr().out+++def test_main_clean_file_returns_zero(+    tmp_path: Path, capsys: pytest.CaptureFixture[str]+) -> None:+    path = tmp_path / "mod.py"+    path.write_text("a = [y for y in ys]\n")+    assert main([str(path)]) == 0+    assert "0 PEP 798 opportunities found" in capsys.readouterr().out+++def test_main_skips_syntax_errors(+    tmp_path: Path, capsys: pytest.CaptureFixture[str]+) -> None:+    path = tmp_path / "bad.py"+    path.write_text("def (:\n")+    assert main([str(path)]) == 0+    assert "skipped" in capsys.readouterr().err