From c702f779184aabd48e43948626ab18b3490fb30b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:12:04 +0900 Subject: [PATCH] feat(omo-codex): batch 23 (3 files) --- .../scripts/python/check-no-excuse-rules.py | 687 ++++++++++++++++++ .../programming/scripts/python/new-project.py | 172 +++++ .../programming/scripts/python/new-script.py | 116 +++ 3 files changed, 975 insertions(+) create mode 100644 packages/omo-codex/plugin/skills/programming/scripts/python/check-no-excuse-rules.py create mode 100644 packages/omo-codex/plugin/skills/programming/scripts/python/new-project.py create mode 100644 packages/omo-codex/plugin/skills/programming/scripts/python/new-script.py diff --git a/packages/omo-codex/plugin/skills/programming/scripts/python/check-no-excuse-rules.py b/packages/omo-codex/plugin/skills/programming/scripts/python/check-no-excuse-rules.py new file mode 100644 index 000000000..4270ef321 --- /dev/null +++ b/packages/omo-codex/plugin/skills/programming/scripts/python/check-no-excuse-rules.py @@ -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 ... + +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=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()) diff --git a/packages/omo-codex/plugin/skills/programming/scripts/python/new-project.py b/packages/omo-codex/plugin/skills/programming/scripts/python/new-project.py new file mode 100644 index 000000000..da0f74d3a --- /dev/null +++ b/packages/omo-codex/plugin/skills/programming/scripts/python/new-project.py @@ -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) diff --git a/packages/omo-codex/plugin/skills/programming/scripts/python/new-script.py b/packages/omo-codex/plugin/skills/programming/scripts/python/new-script.py new file mode 100644 index 000000000..3e9973680 --- /dev/null +++ b/packages/omo-codex/plugin/skills/programming/scripts/python/new-script.py @@ -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)