feat(shared-skills): batch 26 (3 files)
This commit is contained in:
@@ -0,0 +1,687 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = []
|
||||
# ///
|
||||
# noqa: SIZE_OK — single self-contained checker, splitting adds import ceremony for no readability gain
|
||||
"""Check Python files for no-excuse violations.
|
||||
|
||||
The python-programmer skill enforces these rules. Run after editing.
|
||||
|
||||
Rules:
|
||||
cast-any - cast(Any, ...) / cast(typing.Any, ...) / typing.cast(Any, ...)
|
||||
type-ignore - `# type: ignore` comments (any variant)
|
||||
pyright-ignore - `# pyright: ignore` comments (any variant)
|
||||
bare-except - `except:` with no class
|
||||
silent-except - `except X: pass` or `except X: ...` (single statement)
|
||||
no-asyncio - `import asyncio` / `from asyncio import ...`
|
||||
Opt out per import line: trailing `# noqa: ANYIO_OK`
|
||||
no-pandas - `import pandas` / `from pandas import ...`
|
||||
Opt out per import line: trailing `# noqa: PANDAS_OK`
|
||||
mutable-dataclass - @dataclass without frozen=True
|
||||
Opt out: trailing `# noqa: MUTABLE_OK`
|
||||
missing-slots - @dataclass without slots=True
|
||||
Opt out: trailing `# noqa: SLOTS_OK`
|
||||
raw-dict-return - function returns bare `dict` type
|
||||
Opt out: trailing `# noqa: DICT_OK`
|
||||
missing-assert-never - match statement without assert_never in default case
|
||||
Opt out: `# noqa: MATCH_OK` on the match line
|
||||
generic-exception - raise ValueError/TypeError/RuntimeError with bare string
|
||||
Opt out: trailing `# noqa: GENERIC_ERR_OK`
|
||||
no-object - `object` used as type annotation (param, return, variable)
|
||||
Opt out: trailing `# noqa: OBJECT_OK`
|
||||
if-elif-on-variant - isinstance/enum-comparison if/elif chain (should be match/case)
|
||||
Opt out: trailing `# noqa: IF_VARIANT_OK`
|
||||
oversized-module - file exceeds 250 pure LOC (non-blank, non-comment)
|
||||
Opt out: `# noqa: SIZE_OK` in first 10 lines
|
||||
broad-except - `except Exception` / `except BaseException` (too broad)
|
||||
Opt out: trailing `# noqa: BROAD_EXCEPT_OK`
|
||||
|
||||
Usage:
|
||||
check-no-excuse-rules.py <file-or-dir>...
|
||||
|
||||
Exit codes:
|
||||
0 - no violations
|
||||
1 - one or more violations
|
||||
2 - input error (path missing, etc.)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import io
|
||||
import re
|
||||
import sys
|
||||
import tokenize
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
EXCLUDED_DIRS = frozenset({
|
||||
".git", ".hg", ".svn", ".venv", "venv", "env", ".env",
|
||||
"__pycache__", ".tox", ".nox", "dist", "build", ".eggs",
|
||||
".ruff_cache", ".mypy_cache", ".pytest_cache", ".basedpyright",
|
||||
"node_modules",
|
||||
})
|
||||
|
||||
SUPPRESSION_RE = re.compile(r"#\s*(type|pyright)\s*:\s*ignore\b")
|
||||
ANYIO_OK_RE = re.compile(r"#\s*noqa:\s*ANYIO_OK\b")
|
||||
PANDAS_OK_RE = re.compile(r"#\s*noqa:\s*PANDAS_OK\b")
|
||||
|
||||
BANNED_IMPORTS: dict[str, tuple[str, re.Pattern[str], str]] = {
|
||||
"asyncio": (
|
||||
"no-asyncio",
|
||||
ANYIO_OK_RE,
|
||||
"import asyncio - use anyio (opt out: trailing `# noqa: ANYIO_OK`)",
|
||||
),
|
||||
"pandas": (
|
||||
"no-pandas",
|
||||
PANDAS_OK_RE,
|
||||
"import pandas - use polars (opt out: trailing `# noqa: PANDAS_OK`)",
|
||||
),
|
||||
}
|
||||
|
||||
# Opt-out patterns for new Rust-like rules
|
||||
MUTABLE_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*MUTABLE_OK")
|
||||
SLOTS_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*SLOTS_OK")
|
||||
DICT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*DICT_OK")
|
||||
MATCH_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*MATCH_OK")
|
||||
GENERIC_ERR_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*GENERIC_ERR_OK")
|
||||
OBJECT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*OBJECT_OK")
|
||||
IF_VARIANT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*IF_VARIANT_OK")
|
||||
SIZE_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*SIZE_OK")
|
||||
BROAD_EXCEPT_OK_RE: re.Pattern[str] = re.compile(r"#\s*noqa:\s*BROAD_EXCEPT_OK")
|
||||
|
||||
PURE_LOC_LIMIT: int = 250
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Violation:
|
||||
rule: str
|
||||
file: Path
|
||||
line: int
|
||||
col: int
|
||||
message: str
|
||||
|
||||
def render(self) -> str:
|
||||
return f"{self.file}:{self.line}:{self.col}: [{self.rule}] {self.message}"
|
||||
|
||||
|
||||
def discover_files(inputs: Iterable[Path]) -> list[Path]:
|
||||
seen: set[Path] = set()
|
||||
for raw in inputs:
|
||||
path = raw.resolve()
|
||||
if not path.exists():
|
||||
print(f"check-no-excuse-rules: input does not exist: {path}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
if path.is_file():
|
||||
if path.suffix == ".py":
|
||||
seen.add(path)
|
||||
continue
|
||||
for child in path.rglob("*.py"):
|
||||
if any(part in EXCLUDED_DIRS for part in child.parts):
|
||||
continue
|
||||
seen.add(child)
|
||||
return sorted(seen)
|
||||
|
||||
|
||||
def is_any_node(node: ast.AST) -> bool:
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id == "Any"
|
||||
if isinstance(node, ast.Attribute):
|
||||
return node.attr == "Any"
|
||||
return False
|
||||
|
||||
|
||||
def is_cast_callable(node: ast.AST) -> bool:
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id == "cast"
|
||||
if isinstance(node, ast.Attribute):
|
||||
return node.attr == "cast"
|
||||
return False
|
||||
|
||||
|
||||
def find_node_violations(tree: ast.AST, file: Path) -> list[Violation]:
|
||||
violations: list[Violation] = []
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if (
|
||||
isinstance(node, ast.Call)
|
||||
and is_cast_callable(node.func)
|
||||
and node.args
|
||||
and is_any_node(node.args[0])
|
||||
):
|
||||
violations.append(Violation(
|
||||
rule="cast-any",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message="cast(Any, ...) - narrow with isinstance/TypeGuard or use a Protocol/TypedDict",
|
||||
))
|
||||
|
||||
if isinstance(node, ast.ExceptHandler):
|
||||
if node.type is None:
|
||||
violations.append(Violation(
|
||||
rule="bare-except",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message="bare `except:` - catch the narrowest exception you mean",
|
||||
))
|
||||
|
||||
if len(node.body) != 1:
|
||||
continue
|
||||
|
||||
body = node.body[0]
|
||||
if isinstance(body, ast.Pass):
|
||||
violations.append(Violation(
|
||||
rule="silent-except",
|
||||
file=file,
|
||||
line=body.lineno,
|
||||
col=body.col_offset + 1,
|
||||
message="silent `except: pass` - log, re-raise, or actually handle the error",
|
||||
))
|
||||
elif (
|
||||
isinstance(body, ast.Expr)
|
||||
and isinstance(body.value, ast.Constant)
|
||||
and body.value.value is Ellipsis
|
||||
):
|
||||
violations.append(Violation(
|
||||
rule="silent-except",
|
||||
file=file,
|
||||
line=body.lineno,
|
||||
col=body.col_offset + 1,
|
||||
message="silent `except: ...` - log, re-raise, or actually handle the error",
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def find_import_violations(tree: ast.AST, source_lines: list[str], file: Path) -> list[Violation]:
|
||||
violations: list[Violation] = []
|
||||
|
||||
def line_text(lineno: int) -> str:
|
||||
index = lineno - 1
|
||||
return source_lines[index] if 0 <= index < len(source_lines) else ""
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import): # noqa: IF_VARIANT_OK — filtering walk, not closed union
|
||||
for alias in node.names:
|
||||
top = alias.name.split(".")[0]
|
||||
if top not in BANNED_IMPORTS:
|
||||
continue
|
||||
rule, opt_re, message = BANNED_IMPORTS[top]
|
||||
if opt_re.search(line_text(node.lineno)):
|
||||
continue
|
||||
violations.append(Violation(
|
||||
rule=rule,
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message=message,
|
||||
))
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
top = (node.module or "").split(".")[0]
|
||||
if top not in BANNED_IMPORTS:
|
||||
continue
|
||||
rule, opt_re, message = BANNED_IMPORTS[top]
|
||||
if opt_re.search(line_text(node.lineno)):
|
||||
continue
|
||||
violations.append(Violation(
|
||||
rule=rule,
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message=message,
|
||||
))
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def find_comment_violations(source: str, file: Path) -> list[Violation]:
|
||||
"""Use tokenize so we don't false-match `# type: ignore` inside string literals."""
|
||||
violations: list[Violation] = []
|
||||
try:
|
||||
tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))
|
||||
except tokenize.TokenError as exc:
|
||||
print(f"check-no-excuse-rules: tokenize failed for {file}: {exc}", file=sys.stderr)
|
||||
return violations
|
||||
|
||||
for tok in tokens:
|
||||
if tok.type != tokenize.COMMENT:
|
||||
continue
|
||||
match = SUPPRESSION_RE.search(tok.string)
|
||||
if not match:
|
||||
continue
|
||||
kind = match.group(1)
|
||||
rule = "type-ignore" if kind == "type" else "pyright-ignore"
|
||||
violations.append(Violation(
|
||||
rule=rule,
|
||||
file=file,
|
||||
line=tok.start[0],
|
||||
col=tok.start[1] + match.start() + 1,
|
||||
message=f"`# {kind}: ignore` - fix the underlying type instead",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Rust-like pattern checks
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _has_keyword(decorator_node: ast.Call, keyword: str) -> bool | None:
|
||||
"""Check if a decorator call has a specific keyword argument.
|
||||
|
||||
Returns True if keyword is True, False if keyword is False or absent, None if not a Call.
|
||||
"""
|
||||
for kw in decorator_node.keywords:
|
||||
if kw.arg == keyword and isinstance(kw.value, ast.Constant):
|
||||
return bool(kw.value.value)
|
||||
return False
|
||||
|
||||
|
||||
def _is_dataclass_decorator(node: ast.expr) -> tuple[bool, ast.Call | None]:
|
||||
"""Return (is_dataclass, call_node_or_None)."""
|
||||
if isinstance(node, ast.Name) and node.id == "dataclass":
|
||||
return True, None
|
||||
if isinstance(node, ast.Attribute) and node.attr == "dataclass":
|
||||
return True, None
|
||||
if isinstance(node, ast.Call):
|
||||
inner, _ = _is_dataclass_decorator(node.func)
|
||||
if inner:
|
||||
return True, node
|
||||
return False, None
|
||||
|
||||
|
||||
def find_dataclass_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check @dataclass decorators for frozen=True and slots=True."""
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
for dec in node.decorator_list:
|
||||
is_dc, call_node = _is_dataclass_decorator(dec)
|
||||
if not is_dc:
|
||||
continue
|
||||
|
||||
# Get the line of the decorator for opt-out check
|
||||
dec_line = source_lines[dec.lineno - 1] if dec.lineno <= len(source_lines) else ""
|
||||
|
||||
if call_node is not None:
|
||||
has_frozen = _has_keyword(call_node, "frozen")
|
||||
has_slots = _has_keyword(call_node, "slots")
|
||||
else:
|
||||
# bare @dataclass with no arguments
|
||||
has_frozen = False
|
||||
has_slots = False
|
||||
|
||||
if not has_frozen and not MUTABLE_OK_RE.search(dec_line):
|
||||
violations.append(Violation(
|
||||
rule="mutable-dataclass",
|
||||
file=file,
|
||||
line=dec.lineno,
|
||||
col=dec.col_offset + 1,
|
||||
message=f"class {node.name}: @dataclass without frozen=True",
|
||||
))
|
||||
|
||||
if not has_slots and not SLOTS_OK_RE.search(dec_line):
|
||||
violations.append(Violation(
|
||||
rule="missing-slots",
|
||||
file=file,
|
||||
line=dec.lineno,
|
||||
col=dec.col_offset + 1,
|
||||
message=f"class {node.name}: @dataclass without slots=True",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def find_dict_return_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check for functions returning bare `dict` type."""
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
continue
|
||||
ret = node.returns
|
||||
if ret is None:
|
||||
continue
|
||||
# Check for bare `dict` return annotation
|
||||
is_bare_dict = (
|
||||
(isinstance(ret, ast.Name) and ret.id == "dict")
|
||||
or (isinstance(ret, ast.Attribute) and ret.attr == "dict")
|
||||
)
|
||||
if not is_bare_dict:
|
||||
continue
|
||||
|
||||
func_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
||||
if DICT_OK_RE.search(func_line):
|
||||
continue
|
||||
|
||||
violations.append(Violation(
|
||||
rule="raw-dict-return",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message=f"`{node.name}` returns bare dict - use TypedDict/dataclass/Pydantic model",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def find_match_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check match statements for assert_never in default case."""
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Match):
|
||||
continue
|
||||
|
||||
match_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
||||
if MATCH_OK_RE.search(match_line):
|
||||
continue
|
||||
|
||||
has_assert_never = False
|
||||
for case in node.cases:
|
||||
# Wildcard: `case _:` -> MatchAs(pattern=None, name=None)
|
||||
# `case _ as x:` -> MatchAs(pattern=MatchAs(pattern=None, name=None), name="x")
|
||||
pattern = case.pattern
|
||||
is_wildcard = (
|
||||
isinstance(pattern, ast.MatchAs)
|
||||
and (
|
||||
pattern.pattern is None
|
||||
or (
|
||||
isinstance(pattern.pattern, ast.MatchAs)
|
||||
and pattern.pattern.pattern is None
|
||||
and pattern.pattern.name is None
|
||||
)
|
||||
)
|
||||
)
|
||||
if not is_wildcard:
|
||||
continue
|
||||
# Check if body contains assert_never call
|
||||
for stmt in case.body:
|
||||
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call):
|
||||
func = stmt.value.func
|
||||
if (
|
||||
(isinstance(func, ast.Name) and func.id == "assert_never")
|
||||
or (isinstance(func, ast.Attribute) and func.attr == "assert_never")
|
||||
):
|
||||
has_assert_never = True
|
||||
break
|
||||
|
||||
if not has_assert_never:
|
||||
violations.append(Violation(
|
||||
rule="missing-assert-never",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message="match without `case _: assert_never(x)` default",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def find_generic_exception_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check for raise ValueError/TypeError/RuntimeError with bare string or f-string."""
|
||||
GENERIC_EXCEPTIONS = {"ValueError", "TypeError", "RuntimeError", "KeyError"}
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Raise) or node.exc is None:
|
||||
continue
|
||||
exc = node.exc
|
||||
# Match: raise SomeError("string literal")
|
||||
if not isinstance(exc, ast.Call):
|
||||
continue
|
||||
func = exc.func
|
||||
exc_name: str | None = None
|
||||
if isinstance(func, ast.Name) and func.id in GENERIC_EXCEPTIONS:
|
||||
exc_name = func.id
|
||||
elif isinstance(func, ast.Attribute) and func.attr in GENERIC_EXCEPTIONS:
|
||||
exc_name = func.attr
|
||||
if exc_name is None:
|
||||
continue
|
||||
# Check if all arguments are string literals or f-strings
|
||||
if not exc.args:
|
||||
continue
|
||||
all_str = all(
|
||||
(isinstance(arg, ast.Constant) and isinstance(arg.value, str))
|
||||
or isinstance(arg, ast.JoinedStr)
|
||||
for arg in exc.args
|
||||
)
|
||||
if not all_str:
|
||||
continue
|
||||
|
||||
raise_line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
||||
if GENERIC_ERR_OK_RE.search(raise_line):
|
||||
continue
|
||||
|
||||
violations.append(Violation(
|
||||
rule="generic-exception",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message=f"`raise {exc_name}(\"...\")` - define a typed error class instead",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def _is_isinstance_test(node: ast.expr) -> bool:
|
||||
"""Check if node is an isinstance() call."""
|
||||
return (
|
||||
isinstance(node, ast.Call)
|
||||
and isinstance(node.func, ast.Name)
|
||||
and node.func.id == "isinstance"
|
||||
)
|
||||
|
||||
|
||||
def _is_enum_comparison(node: ast.expr) -> bool:
|
||||
"""Check if node is `x == Enum.VALUE` or `x is Enum.VALUE`."""
|
||||
if isinstance(node, ast.Compare) and len(node.ops) == 1:
|
||||
op = node.ops[0]
|
||||
if isinstance(op, (ast.Eq, ast.Is)):
|
||||
comparator = node.comparators[0]
|
||||
# x == Enum.VALUE (attribute access on the right)
|
||||
if isinstance(comparator, ast.Attribute):
|
||||
return True
|
||||
# Enum.VALUE == x (attribute access on the left)
|
||||
if isinstance(node.left, ast.Attribute):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_object_annotation_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check for `object` used as a type annotation."""
|
||||
violations: list[Violation] = []
|
||||
|
||||
def _check_annotation(ann: ast.expr | None) -> None:
|
||||
if ann is None:
|
||||
return
|
||||
for child in ast.walk(ann):
|
||||
if isinstance(child, ast.Name) and child.id == "object":
|
||||
line = source_lines[child.lineno - 1] if child.lineno <= len(source_lines) else ""
|
||||
if OBJECT_OK_RE.search(line):
|
||||
return
|
||||
violations.append(Violation(
|
||||
rule="no-object",
|
||||
file=file,
|
||||
line=child.lineno,
|
||||
col=child.col_offset + 1,
|
||||
message="`object` as type annotation \u2014 use Protocol, TypeVar, or union",
|
||||
))
|
||||
|
||||
for node in ast.walk(tree):
|
||||
match node: # noqa: MATCH_OK — filtering walk, not discriminating a closed union
|
||||
case ast.FunctionDef() | ast.AsyncFunctionDef():
|
||||
all_args = (
|
||||
node.args.args
|
||||
+ node.args.posonlyargs
|
||||
+ node.args.kwonlyargs
|
||||
)
|
||||
for arg in all_args:
|
||||
_check_annotation(arg.annotation)
|
||||
if node.args.vararg:
|
||||
_check_annotation(node.args.vararg.annotation)
|
||||
if node.args.kwarg:
|
||||
_check_annotation(node.args.kwarg.annotation)
|
||||
_check_annotation(node.returns)
|
||||
case ast.AnnAssign():
|
||||
_check_annotation(node.annotation)
|
||||
return violations
|
||||
|
||||
|
||||
def find_if_elif_variant_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check for if/elif chains on isinstance or enum comparison."""
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.If):
|
||||
continue
|
||||
|
||||
line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
||||
if IF_VARIANT_OK_RE.search(line):
|
||||
continue
|
||||
|
||||
is_variant_test = _is_isinstance_test(node.test) or _is_enum_comparison(node.test)
|
||||
if not is_variant_test:
|
||||
continue
|
||||
|
||||
# Must have at least one elif that is also a variant test
|
||||
orelse = node.orelse
|
||||
while orelse and len(orelse) == 1 and isinstance(orelse[0], ast.If):
|
||||
elif_node = orelse[0]
|
||||
if _is_isinstance_test(elif_node.test) or _is_enum_comparison(elif_node.test):
|
||||
violations.append(Violation(
|
||||
rule="if-elif-on-variant",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message="isinstance/enum if/elif chain \u2014 use match/case + assert_never",
|
||||
))
|
||||
break
|
||||
orelse = elif_node.orelse
|
||||
return violations
|
||||
|
||||
|
||||
def find_broad_except_violations(
|
||||
tree: ast.Module, source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check for except Exception / except BaseException (too broad)."""
|
||||
BROAD_EXCEPTIONS = {"Exception", "BaseException"}
|
||||
violations: list[Violation] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ExceptHandler):
|
||||
continue
|
||||
if node.type is None:
|
||||
continue # already caught by bare-except
|
||||
|
||||
exc_name: str | None = None
|
||||
if isinstance(node.type, ast.Name) and node.type.id in BROAD_EXCEPTIONS:
|
||||
exc_name = node.type.id
|
||||
elif isinstance(node.type, ast.Attribute) and node.type.attr in BROAD_EXCEPTIONS:
|
||||
exc_name = node.type.attr
|
||||
if exc_name is None:
|
||||
continue
|
||||
|
||||
line = source_lines[node.lineno - 1] if node.lineno <= len(source_lines) else ""
|
||||
if BROAD_EXCEPT_OK_RE.search(line):
|
||||
continue
|
||||
|
||||
violations.append(Violation(
|
||||
rule="broad-except",
|
||||
file=file,
|
||||
line=node.lineno,
|
||||
col=node.col_offset + 1,
|
||||
message=f"`except {exc_name}` is too broad \u2014 catch the specific exception you expect",
|
||||
))
|
||||
return violations
|
||||
|
||||
|
||||
def find_oversized_module_violations(
|
||||
source_lines: list[str], file: Path,
|
||||
) -> list[Violation]:
|
||||
"""Check if file exceeds 250 pure LOC (non-blank, non-comment)."""
|
||||
# File-level opt-out in first 10 lines (shebang + script metadata can push it down)
|
||||
for line in source_lines[:10]:
|
||||
if SIZE_OK_RE.search(line):
|
||||
return []
|
||||
|
||||
pure_loc = sum(
|
||||
1 for line in source_lines
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
)
|
||||
if pure_loc > PURE_LOC_LIMIT:
|
||||
return [Violation(
|
||||
rule="oversized-module",
|
||||
file=file,
|
||||
line=1,
|
||||
col=1,
|
||||
message=f"{pure_loc} pure LOC (limit: {PURE_LOC_LIMIT}) \u2014 split by responsibility",
|
||||
)]
|
||||
return []
|
||||
|
||||
|
||||
def check_file(file: Path) -> list[Violation]:
|
||||
source = file.read_text(encoding="utf-8")
|
||||
try:
|
||||
tree = ast.parse(source, filename=str(file))
|
||||
except SyntaxError as exc:
|
||||
return [Violation(
|
||||
rule="syntax-error",
|
||||
file=file,
|
||||
line=exc.lineno or 1,
|
||||
col=exc.offset or 1,
|
||||
message=f"SyntaxError: {exc.msg}",
|
||||
)]
|
||||
|
||||
source_lines = source.splitlines()
|
||||
return [
|
||||
*find_node_violations(tree, file),
|
||||
*find_import_violations(tree, source_lines, file),
|
||||
*find_comment_violations(source, file),
|
||||
*find_dataclass_violations(tree, source_lines, file),
|
||||
*find_dict_return_violations(tree, source_lines, file),
|
||||
*find_match_violations(tree, source_lines, file),
|
||||
*find_generic_exception_violations(tree, source_lines, file),
|
||||
*find_object_annotation_violations(tree, source_lines, file),
|
||||
*find_if_elif_variant_violations(tree, source_lines, file),
|
||||
*find_oversized_module_violations(source_lines, file),
|
||||
*find_broad_except_violations(tree, source_lines, file),
|
||||
]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 2:
|
||||
print("usage: check-no-excuse-rules.py <file-or-dir>...", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
files = discover_files(Path(arg) for arg in sys.argv[1:])
|
||||
if not files:
|
||||
print("check-no-excuse-rules: no .py files found", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
violations: list[Violation] = []
|
||||
for file in files:
|
||||
violations.extend(check_file(file))
|
||||
|
||||
if not violations:
|
||||
print(f"no violations in {len(files)} file(s)")
|
||||
return 0
|
||||
|
||||
for violation in violations:
|
||||
print(violation.render(), file=sys.stderr)
|
||||
print(
|
||||
f"\n{len(violations)} violation(s) in {len(files)} file(s)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,172 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "typer",
|
||||
# "rich",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv (if not installed):
|
||||
# curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run:
|
||||
# uv run new-project.py myproject
|
||||
# uv run new-project.py myproject --path ./workspace
|
||||
# uv run new-project.py myproject --lib # library (publishable)
|
||||
# ──────────────────
|
||||
|
||||
"""Scaffold a new Python project with ultra-strict config from pyproject-strict.md.
|
||||
|
||||
Creates via `uv init`, then injects basedpyright + ruff ALL + pytest config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich import print as rprint
|
||||
|
||||
# ── Strict tool config (from pyproject-strict.md) ──
|
||||
|
||||
TOOL_CONFIG = '''
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"basedpyright>=1.21",
|
||||
"ruff>=0.8",
|
||||
"pytest>=8",
|
||||
"pytest-cov>=5",
|
||||
]
|
||||
|
||||
[tool.basedpyright]
|
||||
typeCheckingMode = "all"
|
||||
pythonVersion = "3.13"
|
||||
reportMissingTypeStubs = false
|
||||
reportUnknownMemberType = false
|
||||
reportUnknownArgumentType = false
|
||||
reportUnknownVariableType = false
|
||||
reportUnknownLambdaType = false
|
||||
reportUnknownParameterType = false
|
||||
reportMissingParameterType = false
|
||||
reportUnnecessaryIsInstance = false
|
||||
reportUnusedCallResult = false
|
||||
reportImplicitOverride = false
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
line-length = 120
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"COM812", # trailing comma (conflicts with formatter)
|
||||
"ISC001", # single-line string concat (conflicts with formatter)
|
||||
"D1", # undocumented-public-* (too noisy early on)
|
||||
"ANN101", # deprecated: self annotation
|
||||
"ANN102", # deprecated: cls annotation
|
||||
"S101", # assert used (pytest needs it)
|
||||
"PLR2004", # magic-value-comparison (test data)
|
||||
"FBT", # boolean-trap (too strict for CLIs)
|
||||
"TD", # flake8-todos (noisy)
|
||||
"FIX", # fixme (noisy)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**/*.py" = ["S101", "PLR2004", "SLF001", "D", "ARG", "ANN"]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-ra --strict-markers --strict-config"
|
||||
'''
|
||||
|
||||
GITIGNORE = """\
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.venv/
|
||||
dist/
|
||||
*.egg-info/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.basedpyright/
|
||||
.ruff_cache/
|
||||
"""
|
||||
|
||||
|
||||
def main(
|
||||
name: str = typer.Argument(help="Project name"),
|
||||
path: Path = typer.Option(Path("."), "--path", "-p", help="Parent directory"),
|
||||
lib: bool = typer.Option(False, "--lib", help="Create as publishable library (uv init --lib)"),
|
||||
) -> None:
|
||||
"""Create a new Python project with ultra-strict config."""
|
||||
project_dir = path / name
|
||||
|
||||
if project_dir.exists():
|
||||
rprint(f"[red]Error:[/red] {project_dir} already exists")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Run uv init
|
||||
cmd = ["uv", "init", "--lib" if lib else "--app", str(project_dir)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
rprint(f"[red]uv init failed:[/red] {result.stderr}")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Read existing pyproject.toml
|
||||
pyproject_path = project_dir / "pyproject.toml"
|
||||
content = pyproject_path.read_text()
|
||||
|
||||
# Remove the default [dependency-groups] if uv init created one
|
||||
# (we'll replace it with our strict version)
|
||||
lines = content.splitlines(keepends=True)
|
||||
filtered: list[str] = []
|
||||
skip = False
|
||||
for line in lines:
|
||||
if line.strip().startswith("[dependency-groups]"):
|
||||
skip = True
|
||||
continue
|
||||
if skip and line.strip().startswith("["):
|
||||
skip = False
|
||||
if not skip:
|
||||
filtered.append(line)
|
||||
|
||||
content = "".join(filtered).rstrip("\n") + "\n"
|
||||
|
||||
# Append strict tool config
|
||||
content += TOOL_CONFIG
|
||||
|
||||
pyproject_path.write_text(content)
|
||||
|
||||
# Add dev dependencies
|
||||
subprocess.run(
|
||||
["uv", "add", "--dev", "basedpyright", "ruff", "pytest", "pytest-cov"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
# Create tests directory
|
||||
tests_dir = project_dir / "tests"
|
||||
tests_dir.mkdir(exist_ok=True)
|
||||
(tests_dir / "__init__.py").touch()
|
||||
|
||||
# Overwrite .gitignore
|
||||
(project_dir / ".gitignore").write_text(GITIGNORE)
|
||||
|
||||
# Create py.typed marker for libraries
|
||||
if lib:
|
||||
src_dir = project_dir / "src" / name.replace("-", "_")
|
||||
if src_dir.exists():
|
||||
(src_dir / "py.typed").touch()
|
||||
|
||||
rprint(f"[green]✓[/green] Created: [bold]{project_dir}[/bold]")
|
||||
rprint(f" cd {name} && uv sync && uv run basedpyright . && uv run ruff check .")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
typer.run(main)
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = [
|
||||
# "typer",
|
||||
# "rich",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv (if not installed):
|
||||
# curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run:
|
||||
# uv run new-script.py my_tool
|
||||
# uv run new-script.py my_tool --output ./scripts/my_tool.py
|
||||
# uv run new-script.py my_tool --deps 'httpx2[http2,brotli,zstd]' --deps rich --deps polars
|
||||
# uv run new-script.py my_tool --py 3.13
|
||||
# ──────────────────
|
||||
|
||||
"""Generate a PEP 723 Python script with all boilerplate pre-filled.
|
||||
|
||||
Creates a new .py file with:
|
||||
- uv shebang
|
||||
- PEP 723 inline metadata (requires-python + dependencies)
|
||||
- Mandatory "How to run" comment block
|
||||
- from __future__ import annotations
|
||||
- main() + if __name__ guard
|
||||
|
||||
By default writes to a temp directory and prints the path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich import print as rprint
|
||||
|
||||
|
||||
TEMPLATE = '''\
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# requires-python = ">={python_version}"
|
||||
# dependencies = [
|
||||
{deps_block}# ]
|
||||
# ///
|
||||
|
||||
# ─── How to run ───
|
||||
# 1. Install uv (if not installed):
|
||||
# curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# 2. Run directly (no venv, no pip install needed):
|
||||
# uv run {filename} {args_hint}
|
||||
# 3. Or make executable and run:
|
||||
# chmod +x {filename} && ./{filename}
|
||||
# ──────────────────
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""TODO: implement."""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
|
||||
def main(
|
||||
name: str = typer.Argument(help="Script name (without .py extension)"),
|
||||
output: Path | None = typer.Option(None, "--output", "-o", help="Output path. Default: OS temp directory."),
|
||||
deps: list[str] = typer.Option([], "--deps", "-d", help="Dependencies to include (repeat --deps for each)."),
|
||||
py: str = typer.Option("3.13", "--py", help="Minimum Python version."),
|
||||
) -> None:
|
||||
"""Generate a new PEP 723 script with all boilerplate pre-filled."""
|
||||
filename = f"{name}.py" if not name.endswith(".py") else name
|
||||
stem = filename.removesuffix(".py")
|
||||
|
||||
if output is not None:
|
||||
dest = Path(output)
|
||||
else:
|
||||
tmp_dir = Path(tempfile.gettempdir()) / "uv-scripts"
|
||||
tmp_dir.mkdir(exist_ok=True)
|
||||
dest = tmp_dir / filename
|
||||
|
||||
dep_list = deps or []
|
||||
if dep_list:
|
||||
deps_block = "".join(f'# "{d}",\n' for d in dep_list)
|
||||
else:
|
||||
deps_block = '# # add deps here, e.g.: "httpx2[http2,brotli,zstd]"\n'
|
||||
|
||||
content = TEMPLATE.format(
|
||||
python_version=py,
|
||||
deps_block=deps_block,
|
||||
filename=filename,
|
||||
args_hint="",
|
||||
)
|
||||
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_text(content)
|
||||
|
||||
# Make executable on Unix
|
||||
if sys.platform != "win32":
|
||||
st = dest.stat()
|
||||
dest.chmod(st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
rprint(f"[green]✓[/green] Created: [bold]{dest}[/bold]")
|
||||
rprint(f" Run: [cyan]uv run {dest}[/cyan]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
typer.run(main)
|
||||
Reference in New Issue
Block a user