e93507a09c
Lockfile supply-chain audit / lockfile supply-chain audit (push) Has been cancelled
Windows Studio GGUF CI / GPU prebuilt resolves without Visual Studio (push) Has been cancelled
Windows Studio GGUF CI / setup.ps1 unit tests (VS 2026 / CMake guard) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2022) (push) Has been cancelled
Windows Studio GGUF CI / real-VS detection (VS 2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-2025-vs2026) (push) Has been cancelled
Windows Studio GGUF CI / VC++ runtime detect + install round-trip (windows-latest) (push) Has been cancelled
Windows Studio Update CI / Studio Updating Tests (push) Has been cancelled
Wheel CI / Wheel build + content sanity + import smoke (push) Has been cancelled
Lint CI / Source lint (Python + shell + YAML + JSON + safety nets) (push) Has been cancelled
MLX CI on Mac M1 / dispatch (push) Has been cancelled
Security audit / advisory audit (pip + npm + cargo) (push) Has been cancelled
Security audit / pip scan-packages :: extras (push) Has been cancelled
Security audit / pip scan-packages :: studio (push) Has been cancelled
Security audit / pip scan-packages :: hf-stack (push) Has been cancelled
Security audit / npm scan-packages (Studio frontend tarballs) (push) Has been cancelled
Security audit / workflow-trigger lint (pull_request_target / cache-poisoning) (push) Has been cancelled
Security audit / pytest tests/security (push) Has been cancelled
Security audit / npm provenance + new install-script diff (push) Has been cancelled
Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Backend CI / (Python 3.10) (push) Has been cancelled
Backend CI / (Python 3.11) (push) Has been cancelled
Backend CI / (Python 3.12) (push) Has been cancelled
Backend CI / (Python 3.13) (push) Has been cancelled
Backend CI / Repo tests (CPU) (push) Has been cancelled
Frontend CI / Frontend build + bundle sanity (push) Has been cancelled
Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Mac Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Mac Studio GGUF CI / JSON, images (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-14) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26) (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-15-intel) (push) Has been cancelled
Mac Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Mac Studio Install Matrix CI / Install + load (macos-26-intel) (push) Has been cancelled
Mac Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Tauri CI / Tauri Linux debug build (no codesign) (push) Has been cancelled
Mac Studio Update CI / Studio Updating Tests (push) Has been cancelled
Studio UI CI / Chat UI Tests (push) Has been cancelled
Windows Studio API CI / Studio API & Auth Tests (push) Has been cancelled
Windows Studio UI CI / Chat UI Tests (push) Has been cancelled
Studio Update CI / Studio Updating Tests (push) Has been cancelled
Core / Core (HF=default + TRL=default) (push) Has been cancelled
Core / Core (HF=4.57.6 + TRL<1) (push) Has been cancelled
Core / Core (HF=latest + TRL=latest) (push) Has been cancelled
Core / llama.cpp build + smoke (push) Has been cancelled
Windows Studio GGUF CI / OpenAI, Anthropic API tests (push) Has been cancelled
Windows Studio GGUF CI / Tool calling Tests (push) Has been cancelled
Windows Studio GGUF CI / JSON, images (push) Has been cancelled
Windows Studio GGUF CI / Studio install + inference without Visual Studio (push) Has been cancelled
Studio export capability / capability (macos-latest) (push) Has been cancelled
Studio export capability / capability (ubuntu-latest) (push) Has been cancelled
Studio export capability / capability (windows-latest) (push) Has been cancelled
Cross-platform parity / parity (macos-latest) (push) Has been cancelled
Cross-platform parity / parity (windows-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Studio load-orchestrator CI / test (push) Has been cancelled
657 lines
25 KiB
Python
Executable File
657 lines
25 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Ensure keyword arguments use spaces around '=', prune redundant pass statements,
|
|
drop the blank line after a short indented import block, merge adjacent same-line
|
|
string literals, normalize def-signature magic commas (pre-ruff) so a def with
|
|
>= 3 params and a default goes one-per-line while everything else stays
|
|
collapsible, and collapse a short multi-line assert onto one line (pre-ruff) by
|
|
stripping the magic trailing comma that holds it open."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import argparse
|
|
import io
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import tokenize
|
|
from collections import defaultdict
|
|
from pathlib import Path
|
|
|
|
|
|
def _atomic_write_text(path: Path, data: str, encoding: str) -> None:
|
|
"""Write ``data`` to ``path`` atomically via same-dir tmp + fsync + os.replace,
|
|
so a crash mid-write leaves either the old or full new content, never a truncation."""
|
|
dirpath = str(path.parent) or "."
|
|
fd, tmp_path = tempfile.mkstemp(prefix=".kwargs_fix.", dir=dirpath)
|
|
try:
|
|
with os.fdopen(fd, "w", encoding=encoding) as handle:
|
|
handle.write(data)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(tmp_path, path)
|
|
except Exception:
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
raise
|
|
|
|
|
|
def enforce_spacing(text: str) -> tuple[str, bool]:
|
|
"""Return updated text with keyword '=' padded by spaces, plus change flag."""
|
|
lines = text.splitlines(keepends=True)
|
|
if not lines:
|
|
return text, False
|
|
|
|
offsets: dict[int, int] = defaultdict(int)
|
|
changed = False
|
|
|
|
reader = io.StringIO(text).readline
|
|
for token in tokenize.generate_tokens(reader):
|
|
if token.type != tokenize.OP or token.string != "=":
|
|
continue
|
|
|
|
line_index = token.start[0] - 1
|
|
col = token.start[1] + offsets[line_index]
|
|
|
|
if line_index < 0 or line_index >= len(lines):
|
|
continue
|
|
|
|
line = lines[line_index]
|
|
if col >= len(line) or line[col] != "=":
|
|
continue
|
|
|
|
line_changed = False
|
|
|
|
# Insert a space before '=' when missing and not preceded by whitespace.
|
|
if col > 0 and line[col - 1] not in {" ", "\t"}:
|
|
line = f"{line[:col]} {line[col:]}"
|
|
offsets[line_index] += 1
|
|
col += 1
|
|
line_changed = True
|
|
changed = True
|
|
|
|
# Insert a space after '=' when missing and not followed by whitespace or newline.
|
|
next_index = col + 1
|
|
if next_index < len(line) and line[next_index] not in {" ", "\t", "\n", "\r"}:
|
|
line = f"{line[:next_index]} {line[next_index:]}"
|
|
offsets[line_index] += 1
|
|
line_changed = True
|
|
changed = True
|
|
|
|
if line_changed:
|
|
lines[line_index] = line
|
|
|
|
if not changed:
|
|
return text, False
|
|
|
|
return "".join(lines), True
|
|
|
|
|
|
def remove_redundant_passes(text: str) -> tuple[str, bool]:
|
|
"""Drop pass statements that share a block with other executable code."""
|
|
|
|
try:
|
|
tree = ast.parse(text)
|
|
except SyntaxError:
|
|
return text, False
|
|
|
|
redundant: list[ast.Pass] = []
|
|
|
|
def visit(node: ast.AST) -> None:
|
|
for attr in ("body", "orelse", "finalbody"):
|
|
value = getattr(node, attr, None)
|
|
if not isinstance(value, list) or len(value) <= 1:
|
|
continue
|
|
for stmt in value:
|
|
if isinstance(stmt, ast.Pass):
|
|
redundant.append(stmt)
|
|
for stmt in value:
|
|
if isinstance(stmt, ast.AST):
|
|
visit(stmt)
|
|
handlers = getattr(node, "handlers", None)
|
|
if handlers:
|
|
for handler in handlers:
|
|
visit(handler)
|
|
|
|
visit(tree)
|
|
|
|
if not redundant:
|
|
return text, False
|
|
|
|
lines = text.splitlines(keepends=True)
|
|
changed = False
|
|
|
|
for node in sorted(redundant, key=lambda item: (item.lineno, item.col_offset), reverse=True):
|
|
start = node.lineno - 1
|
|
end = (node.end_lineno or node.lineno) - 1
|
|
if start >= len(lines):
|
|
continue
|
|
changed = True
|
|
if start == end:
|
|
line = lines[start]
|
|
col_start = node.col_offset
|
|
col_end = node.end_col_offset or (col_start + 4)
|
|
segment = line[:col_start] + line[col_end:]
|
|
lines[start] = segment if segment.strip() else ""
|
|
continue
|
|
|
|
# Fall-back for unexpected multi-line 'pass'.
|
|
prefix = lines[start][: node.col_offset]
|
|
lines[start] = prefix if prefix.strip() else ""
|
|
for idx in range(start + 1, end):
|
|
lines[idx] = ""
|
|
suffix = lines[end][(node.end_col_offset or 0) :]
|
|
lines[end] = suffix
|
|
|
|
# Normalise to ensure lines end with newlines except at EOF.
|
|
result_lines: list[str] = []
|
|
for index, line in enumerate(lines):
|
|
if not line:
|
|
continue
|
|
if index < len(lines) - 1 and not line.endswith("\n"):
|
|
result_lines.append(f"{line}\n")
|
|
else:
|
|
result_lines.append(line)
|
|
|
|
return "".join(result_lines), changed
|
|
|
|
|
|
def remove_blank_after_short_import(text: str) -> tuple[str, bool]:
|
|
"""Drop blank line(s) after an import block in a small nested suite.
|
|
|
|
In an indented suite of <= 3 statements (never module level), when consecutive
|
|
imports are followed across blank lines (nothing else) by another statement,
|
|
remove those blanks. A comment in the gap blocks the rule. Removing blank lines
|
|
never changes the AST.
|
|
"""
|
|
try:
|
|
tree = ast.parse(text)
|
|
except SyntaxError:
|
|
return text, False
|
|
|
|
lines = text.splitlines(keepends=True)
|
|
import_types = (ast.Import, ast.ImportFrom)
|
|
drop: set[int] = set() # 1-based physical line numbers to delete
|
|
|
|
def suites_of(node: ast.AST) -> list[list[ast.stmt]]:
|
|
if isinstance(node, ast.Module):
|
|
return [] # module-level import spacing is left alone
|
|
out: list[list[ast.stmt]] = []
|
|
for attr in ("body", "orelse", "finalbody"):
|
|
val = getattr(node, attr, None)
|
|
if isinstance(val, list) and val and all(isinstance(s, ast.stmt) for s in val):
|
|
out.append(val)
|
|
return out
|
|
|
|
for node in ast.walk(tree):
|
|
for suite in suites_of(node):
|
|
if len(suite) > 3: # only small blocks
|
|
continue
|
|
i = 0
|
|
while i < len(suite):
|
|
if not isinstance(suite[i], import_types):
|
|
i += 1
|
|
continue
|
|
j = i
|
|
while j + 1 < len(suite) and isinstance(suite[j + 1], import_types):
|
|
j += 1
|
|
if j + 1 < len(suite): # an import block followed by another statement
|
|
last_imp, nxt = suite[j], suite[j + 1]
|
|
gap = range((last_imp.end_lineno or last_imp.lineno) + 1, nxt.lineno)
|
|
nums = [n for n in gap if 1 <= n <= len(lines)]
|
|
if nums and all(lines[n - 1].strip() == "" for n in nums):
|
|
drop.update(nums)
|
|
i = j + 1
|
|
|
|
if not drop:
|
|
return text, False
|
|
kept = [ln for idx, ln in enumerate(lines, start=1) if idx not in drop]
|
|
return "".join(kept), True
|
|
|
|
|
|
_STRING_TRIVIA = (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT, tokenize.INDENT, tokenize.DEDENT)
|
|
|
|
|
|
_DEF_MIN_PARAMS_FOR_MULTILINE = 3 # signatures with < this many params stay one line
|
|
|
|
|
|
def _def_specs_by_line(tree: ast.AST) -> dict[int, tuple[int, bool]]:
|
|
"""Map each def keyword line to (param count, has-any-default).
|
|
|
|
``*`` / ``/`` markers aren't counted. A default exists if any positional default
|
|
is present or any keyword-only default is not ``None`` (``None`` in ``kw_defaults``
|
|
means a required keyword-only arg).
|
|
"""
|
|
out: dict[int, tuple[int, bool]] = {}
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
a = node.args
|
|
count = (
|
|
len(a.posonlyargs)
|
|
+ len(a.args)
|
|
+ len(a.kwonlyargs)
|
|
+ (1 if a.vararg else 0)
|
|
+ (1 if a.kwarg else 0)
|
|
)
|
|
has_default = bool(a.defaults) or any(d is not None for d in a.kw_defaults)
|
|
out[node.lineno] = (count, has_default)
|
|
return out
|
|
|
|
|
|
def normalize_def_trailing_comma(text: str) -> tuple[str, bool]:
|
|
"""Force a def signature one-per-line iff >= 3 params AND a default; else collapsible.
|
|
|
|
A qualifying signature gets a magic trailing comma added (ruff wraps it
|
|
one-per-line); every other signature has its trailing comma stripped so ruff
|
|
collapses it when it fits. Def parameter lists only, never call sites or
|
|
collection literals. Run BEFORE ruff format. Never changes the AST (re-checked).
|
|
"""
|
|
try:
|
|
tree = ast.parse(text)
|
|
toks = list(tokenize.generate_tokens(io.StringIO(text).readline))
|
|
except (tokenize.TokenError, IndentationError, SyntaxError):
|
|
return text, False
|
|
|
|
specs = _def_specs_by_line(tree)
|
|
n = len(toks)
|
|
edits: list[tuple[int, int, str]] = [] # (row, col, "del" | "ins")
|
|
i = 0
|
|
while i < n:
|
|
t = toks[i]
|
|
if t.type == tokenize.NAME and t.string == "def" and t.start[0] in specs:
|
|
cnt, has_default = specs[t.start[0]]
|
|
force_multiline = cnt >= _DEF_MIN_PARAMS_FOR_MULTILINE and has_default
|
|
j = i + 1
|
|
while j < n and not (toks[j].type == tokenize.OP and toks[j].string == "("):
|
|
if toks[j].type == tokenize.NEWLINE:
|
|
break
|
|
j += 1
|
|
if j < n and toks[j].type == tokenize.OP and toks[j].string == "(":
|
|
depth = 0
|
|
k = j
|
|
while k < n:
|
|
tk = toks[k]
|
|
if tk.type == tokenize.OP and tk.string == "(":
|
|
depth += 1
|
|
elif tk.type == tokenize.OP and tk.string == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
m = k - 1
|
|
while m > j and toks[m].type in _STRING_TRIVIA:
|
|
m -= 1
|
|
last = toks[m]
|
|
has_comma = last.type == tokenize.OP and last.string == ","
|
|
empty = m == j # nothing between ( and )
|
|
if force_multiline and not has_comma and not empty:
|
|
edits.append((last.end[0], last.end[1], "ins"))
|
|
elif not force_multiline and has_comma:
|
|
edits.append((last.start[0], last.start[1], "del"))
|
|
break
|
|
k += 1
|
|
i = k + 1
|
|
continue
|
|
i += 1
|
|
|
|
if not edits:
|
|
return text, False
|
|
|
|
lines = text.splitlines(keepends=True)
|
|
for row, col, kind in sorted(edits, reverse=True):
|
|
ln = lines[row - 1]
|
|
if kind == "del":
|
|
if col < len(ln) and ln[col] == ",":
|
|
lines[row - 1] = ln[:col] + ln[col + 1 :]
|
|
else: # ins
|
|
lines[row - 1] = ln[:col] + "," + ln[col:]
|
|
out = "".join(lines)
|
|
try:
|
|
if ast.dump(ast.parse(out)) != ast.dump(ast.parse(text)):
|
|
return text, False
|
|
except SyntaxError:
|
|
return text, False
|
|
return out, True
|
|
|
|
|
|
def _split_string_token(s: str) -> tuple[str, str, str] | None:
|
|
"""Split a string literal source into (prefix, quote, body).
|
|
|
|
``prefix`` is the letters before the opening quote, ``quote`` the delimiter,
|
|
``body`` everything between. ``None`` if not a recognizable string literal.
|
|
"""
|
|
i = 0
|
|
while i < len(s) and s[i] not in ("'", '"'):
|
|
i += 1
|
|
if i >= len(s):
|
|
return None
|
|
prefix, rest = s[:i], s[i:]
|
|
for q in ('"""', "'''", '"', "'"):
|
|
if rest.startswith(q) and rest.endswith(q) and len(rest) >= 2 * len(q):
|
|
return prefix, q, rest[len(q) : len(rest) - len(q)]
|
|
return None
|
|
|
|
|
|
# A "piece" is one string literal in source: a plain STRING token, or a whole
|
|
# f-string spanning FSTRING_START..FSTRING_END. (kind, (row, col0), (row, col1), raw)
|
|
def _string_pieces(
|
|
toks: list[tokenize.TokenInfo], lines: list[str]
|
|
) -> list[tuple[str, tuple[int, int], tuple[int, int], str | None]]:
|
|
pieces: list[tuple[str, tuple[int, int], tuple[int, int], str | None]] = []
|
|
n = len(toks)
|
|
|
|
def raw_of(start: tuple[int, int], end: tuple[int, int]) -> str | None:
|
|
if start[0] != end[0]: # only single-physical-line pieces are mergeable
|
|
return None
|
|
return lines[start[0] - 1][start[1] : end[1]]
|
|
|
|
i = 0
|
|
while i < n:
|
|
t = toks[i]
|
|
if t.type == tokenize.STRING:
|
|
pieces.append(("str", t.start, t.end, raw_of(t.start, t.end)))
|
|
i += 1
|
|
elif t.type == tokenize.FSTRING_START:
|
|
depth = 0
|
|
j = i
|
|
while j < n: # walk to the matching FSTRING_END (f-strings can nest)
|
|
if toks[j].type == tokenize.FSTRING_START:
|
|
depth += 1
|
|
elif toks[j].type == tokenize.FSTRING_END:
|
|
depth -= 1
|
|
if depth == 0:
|
|
break
|
|
j += 1
|
|
end = toks[j].end
|
|
pieces.append(("f", t.start, end, raw_of(t.start, end)))
|
|
i = j + 1
|
|
else:
|
|
pieces.append(("other", t.start, t.end, None))
|
|
i += 1
|
|
return pieces
|
|
|
|
|
|
def _merge_string_run(pieces: list[tuple[str, str]]) -> str | None:
|
|
"""Merge a run of adjacent string pieces into one literal's source text.
|
|
|
|
``pieces`` is ``(kind, raw_source)`` with kind ``"str"`` or ``"f"``. Bytes are
|
|
left side-by-side (``None``); a run with no f-string merges plain/raw/unicode
|
|
sharing one prefix+quote by body concatenation; a run mixing an f-string with a
|
|
plain string (no bytes, no raw) folds into one f-string with plain braces escaped.
|
|
Runs of only f-strings are left alone. Caller re-checks the AST and drops a
|
|
differing change, so subtle cases are caught.
|
|
"""
|
|
parsed = []
|
|
for kind, raw in pieces:
|
|
pqb = _split_string_token(raw)
|
|
if pqb is None:
|
|
return None
|
|
prefix, quote, body = pqb
|
|
if "b" in prefix.lower():
|
|
return None # bytes: leave side-by-side
|
|
parsed.append((kind, prefix, quote, body))
|
|
if len({p[2] for p in parsed}) != 1:
|
|
return None # mixed quote style: not a safe textual merge
|
|
quote = parsed[0][2]
|
|
if not any(p[0] == "f" for p in parsed):
|
|
# No f-string: merge plain/raw/unicode sharing one prefix by concatenation.
|
|
if len({p[1].lower() for p in parsed}) != 1:
|
|
return None
|
|
return f"{parsed[0][1]}{quote}{''.join(p[3] for p in parsed)}{quote}"
|
|
# f-string fold only when a plain string is glued onto an f-string; a run of
|
|
# only f-strings is left side-by-side (folding long ones would force ruff to
|
|
# re-wrap the surrounding statement).
|
|
if all(p[0] == "f" for p in parsed):
|
|
return None
|
|
# raw mixed with f is too subtle (backslash + brace escaping) -> skip.
|
|
if any("r" in p[1].lower() for p in parsed):
|
|
return None
|
|
body = "".join(
|
|
b if kind == "f" else b.replace("{", "{{").replace("}", "}}")
|
|
for kind, _pfx, _q, b in parsed
|
|
)
|
|
return f"f{quote}{body}{quote}"
|
|
|
|
|
|
_LINE_LENGTH = 100 # ruff line-length; an f-fold must not push a statement past it
|
|
|
|
|
|
def _enclosing_stmt(tree: ast.AST, row: int) -> ast.stmt | None:
|
|
"""The innermost statement whose physical-line span contains ``row``."""
|
|
best: tuple[ast.stmt, int] | None = None
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.stmt):
|
|
lo = node.lineno
|
|
hi = node.end_lineno or lo
|
|
if lo <= row <= hi and (best is None or hi - lo < best[1]):
|
|
best = (node, hi - lo)
|
|
return best[0] if best else None
|
|
|
|
|
|
def _fold_collapses(
|
|
tree: ast.AST, lines: list[str], row: int, c0: int, c1: int, merged: str
|
|
) -> bool:
|
|
"""Whether an f-string fold at ``row[c0:c1]`` -> ``merged`` is safe to apply.
|
|
|
|
Only ``assert`` wraps awkwardly when a message folds (ruff parenthesizes the
|
|
condition once it no longer fits one line); every other construct wraps
|
|
acceptably so is always allowed. An ``assert`` fold is allowed only if already
|
|
one line, or its estimated folded one-line length fits the line length.
|
|
"""
|
|
stmt = _enclosing_stmt(tree, row)
|
|
if not isinstance(stmt, ast.Assert):
|
|
return True
|
|
lo, hi = stmt.lineno, stmt.end_lineno or stmt.lineno
|
|
if lo == hi:
|
|
return True
|
|
seg = []
|
|
for k in range(lo, hi + 1):
|
|
ln = lines[k - 1].rstrip("\n")
|
|
if k == row:
|
|
ln = ln[:c0] + merged + ln[c1:]
|
|
seg.append(ln)
|
|
indent = len(seg[0]) - len(seg[0].lstrip())
|
|
# Conservative over-estimate: join continuation lines with a single space
|
|
# (ruff joins bracketed wraps with none), so borderline cases skip the fold.
|
|
joined = " ".join(s.strip() for s in seg)
|
|
return indent + len(joined) <= _LINE_LENGTH
|
|
|
|
|
|
def merge_adjacent_string_literals(text: str) -> tuple[str, bool]:
|
|
"""Merge adjacent string literals on ONE physical line into a single literal.
|
|
|
|
Plain/raw/unicode runs merge by concatenation; an f-string + plain string folds
|
|
into one f-string (plain braces escaped) only while the statement still fits one
|
|
line. Runs of only f-strings, and bytes, are left side-by-side. The file AST is
|
|
re-checked and a differing change dropped, so meaning never changes.
|
|
"""
|
|
try:
|
|
toks = list(tokenize.generate_tokens(io.StringIO(text).readline))
|
|
tree = ast.parse(text)
|
|
except (tokenize.TokenError, IndentationError, SyntaxError):
|
|
return text, False
|
|
|
|
lines = text.splitlines(keepends=True)
|
|
pieces = _string_pieces(toks, lines)
|
|
|
|
# Group consecutive mergeable pieces (str/f, single line, same physical line).
|
|
runs: list[list[tuple[str, tuple[int, int], tuple[int, int], str]]] = []
|
|
cur: list[tuple[str, tuple[int, int], tuple[int, int], str]] = []
|
|
for kind, start, end, raw in pieces:
|
|
if kind in ("str", "f") and raw is not None:
|
|
if cur and cur[-1][2][0] != start[0]:
|
|
if len(cur) >= 2:
|
|
runs.append(cur)
|
|
cur = []
|
|
cur.append((kind, start, end, raw))
|
|
else:
|
|
if len(cur) >= 2:
|
|
runs.append(cur)
|
|
cur = []
|
|
if len(cur) >= 2:
|
|
runs.append(cur)
|
|
if not runs:
|
|
return text, False
|
|
|
|
edits = []
|
|
for run in runs:
|
|
merged = _merge_string_run([(kind, raw) for kind, _s, _e, raw in run])
|
|
if merged is None:
|
|
continue
|
|
row, c0, c1 = run[0][1][0], run[0][1][1], run[-1][2][1]
|
|
# An f-string fold must not push its statement onto extra lines; a plain
|
|
# concatenation always collapses cleanly so it skips this check.
|
|
if any(kind == "f" for kind, _s, _e, _r in run) and not _fold_collapses(
|
|
tree, lines, row, c0, c1, merged
|
|
):
|
|
continue
|
|
edits.append((row, c0, c1, merged))
|
|
if not edits:
|
|
return text, False
|
|
|
|
for row, c0, c1, repl in sorted(edits, key=lambda e: (e[0], e[1]), reverse=True):
|
|
ln = lines[row - 1]
|
|
lines[row - 1] = ln[:c0] + repl + ln[c1:]
|
|
out = "".join(lines)
|
|
try:
|
|
if ast.dump(ast.parse(text)) != ast.dump(ast.parse(out)):
|
|
return text, False
|
|
except SyntaxError:
|
|
return text, False
|
|
return out, True
|
|
|
|
|
|
def collapse_short_asserts(text: str) -> tuple[str, bool]:
|
|
"""Collapse a multi-line ``assert`` onto one line when it would fit.
|
|
|
|
When the statement's estimated one-line length fits, strip the magic trailing
|
|
commas (comma before a closer) holding it open so ruff rejoins it. Run BEFORE
|
|
ruff format. Skips asserts with a comment (would oscillate). Stripping is
|
|
non-semantic except for a one-element tuple; AST is re-checked and changing
|
|
asserts left alone.
|
|
"""
|
|
try:
|
|
tree = ast.parse(text)
|
|
toks = list(tokenize.generate_tokens(io.StringIO(text).readline))
|
|
except (tokenize.TokenError, IndentationError, SyntaxError):
|
|
return text, False
|
|
|
|
lines = text.splitlines(keepends=True)
|
|
multiline = [
|
|
(n.lineno, n.end_lineno)
|
|
for n in ast.walk(tree)
|
|
if isinstance(n, ast.Assert) and (n.end_lineno or n.lineno) > n.lineno
|
|
]
|
|
if not multiline:
|
|
return text, False
|
|
|
|
comment_rows = {t.start[0] for t in toks if t.type == tokenize.COMMENT}
|
|
|
|
targets = [] # (lo, hi) spans whose one-line form fits and have no comment
|
|
for lo, hi in multiline:
|
|
if any(lo <= r <= hi for r in comment_rows):
|
|
continue # a comment would keep ruff multi-line -> never collapses
|
|
seg = [lines[k].rstrip("\n") for k in range(lo - 1, hi)]
|
|
indent = len(seg[0]) - len(seg[0].lstrip())
|
|
# Over-estimate (join with a space; keep the comma) so a "fits" verdict
|
|
# is always at least as long as ruff's real one-line output -> no fight.
|
|
if indent + len(" ".join(s.strip() for s in seg)) <= _LINE_LENGTH:
|
|
targets.append((lo, hi))
|
|
if not targets:
|
|
return text, False
|
|
|
|
# Trailing commas (a ',' whose next significant token is a closer), grouped
|
|
# by the target assert they belong to.
|
|
sig = [t for t in toks if t.type not in _STRING_TRIVIA]
|
|
by_target: dict[tuple[int, int], list[tuple[int, int]]] = defaultdict(list)
|
|
for i, t in enumerate(sig):
|
|
if t.type == tokenize.OP and t.string == ",":
|
|
nxt = sig[i + 1] if i + 1 < len(sig) else None
|
|
if nxt and nxt.type == tokenize.OP and nxt.string in (")", "]", "}"):
|
|
for lo, hi in targets:
|
|
if lo <= t.start[0] <= hi:
|
|
by_target[(lo, hi)].append(t.start)
|
|
break
|
|
if not by_target:
|
|
return text, False
|
|
|
|
base_dump = ast.dump(tree)
|
|
working = lines[:]
|
|
changed = False
|
|
for positions in by_target.values(): # apply per assert; skip any that break AST
|
|
trial = working[:]
|
|
for row, col in sorted(positions, reverse=True):
|
|
ln = trial[row - 1]
|
|
if col < len(ln) and ln[col] == ",":
|
|
trial[row - 1] = ln[:col] + ln[col + 1 :]
|
|
try:
|
|
if ast.dump(ast.parse("".join(trial))) == base_dump:
|
|
working, changed = trial, True
|
|
except SyntaxError:
|
|
pass
|
|
return ("".join(working), True) if changed else (text, False)
|
|
|
|
|
|
def process_file(path: Path, pre: bool = False) -> bool:
|
|
try:
|
|
with tokenize.open(path) as handle:
|
|
original = handle.read()
|
|
encoding = handle.encoding
|
|
except (OSError, SyntaxError) as exc: # SyntaxError from tokenize on invalid python
|
|
print(f"Failed to read {path}: {exc}", file=sys.stderr)
|
|
return False
|
|
|
|
if pre:
|
|
# Pre-ruff: normalize def-signature magic commas (>=3 params + a default
|
|
# add so ruff forces one-per-line; everything else strips so ruff
|
|
# collapses), and strip the magic trailing comma from a short multi-line
|
|
# assert so ruff joins it onto one line. Everything else runs post-ruff.
|
|
updated, normalized = normalize_def_trailing_comma(original)
|
|
updated, collapsed = collapse_short_asserts(updated)
|
|
if normalized or collapsed:
|
|
_atomic_write_text(path, updated, encoding)
|
|
return True
|
|
return False
|
|
|
|
updated, changed = enforce_spacing(original)
|
|
updated, blanked = remove_blank_after_short_import(updated)
|
|
updated, merged = merge_adjacent_string_literals(updated)
|
|
updated, removed = remove_redundant_passes(updated)
|
|
if changed or blanked or merged or removed:
|
|
_atomic_write_text(path, updated, encoding)
|
|
return True
|
|
return False
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("files", nargs="+", help="Python files to fix")
|
|
parser.add_argument(
|
|
"--pre",
|
|
action="store_true",
|
|
help="pre-ruff pass: normalize def-signature commas + collapse short multi-line asserts",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
touched: list[Path] = []
|
|
self_path = Path(__file__).resolve()
|
|
|
|
for entry in args.files:
|
|
path = Path(entry)
|
|
# Skip modifying this script to avoid self-edit loops.
|
|
if path.resolve() == self_path:
|
|
continue
|
|
if not path.exists() or path.is_dir():
|
|
continue
|
|
if process_file(path, pre=args.pre):
|
|
touched.append(path)
|
|
|
|
if touched:
|
|
for path in touched:
|
|
print(f"Adjusted kwarg spacing in {path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|