From 52f1435ad7e791448d32f139036a2d717ead5aa2 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sat, 1 Nov 2025 02:32:05 +0800 Subject: [PATCH] New Google Python Docstring formatter (#630) Co-authored-by: UltralyticsAssistant --- .github/workflows/format.yml | 3 +- .gitignore | 3 + README.md | 5 +- action.yml | 15 +- actions/__init__.py | 1 + actions/format_python_docstrings.py | 511 +++++++++++++++++++++++++ actions/update_markdown_code_blocks.py | 16 +- pyproject.toml | 9 +- 8 files changed, 533 insertions(+), 30 deletions(-) create mode 100644 actions/format_python_docstrings.py diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 07c1f60..34716c0 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -26,7 +26,8 @@ jobs: with: token: ${{ secrets._GITHUB_TOKEN || github.token }} # Auto-generated fallback labels: true # Auto-label issues/PRs using AI - python: true # Format Python with Ruff and docformatter + python: true # Format Python with Ruff + python_docstrings: true # Format Python docstrings prettier: true # Format YAML, JSON, Markdown, CSS spelling: true # Check spelling with codespell links: false # Check broken links with Lychee diff --git a/.gitignore b/.gitignore index 2dc53ca..b00e58d 100644 --- a/.gitignore +++ b/.gitignore @@ -158,3 +158,6 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ + +# Extras +test_docstring_formatter.py diff --git a/README.md b/README.md index a3b1dc3..968c73a 100644 --- a/README.md +++ b/README.md @@ -30,8 +30,8 @@ AI-powered formatting, labeling, and PR summaries for Python, Swift, and Markdow ### 📄 Features - **Python Code:** Formatted using [Ruff](https://github.com/astral-sh/ruff), an extremely fast Python linter and formatter +- **Python Docstrings:** Google-style formatting enforced with Ultralytics Python docstring formatter (optional) - **Markdown Files:** Styled with [Prettier](https://github.com/prettier/prettier) to ensure consistent documentation appearance -- **Docstrings:** Cleaned and standardized using [docformatter](https://github.com/PyCQA/docformatter) - **Swift Code:** Formatted with [`swift-format`](https://github.com/swiftlang/swift-format) _(requires `macos-latest` runner)_ - **Spell Check:** Common misspellings caught using [codespell](https://github.com/codespell-project/codespell) - **Broken Links Check:** Broken links identified using [Lychee](https://github.com/lycheeverse/lychee) @@ -80,7 +80,8 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} # Auto-generated token labels: true # Auto-label issues/PRs using AI - python: true # Format Python with Ruff and docformatter + python: true # Format Python with Ruff + python_docstrings: false # Format Python docstrings (default: false) prettier: true # Format YAML, JSON, Markdown, CSS swift: false # Format Swift (requires macos-latest) dart: false # Format Dart/Flutter diff --git a/action.yml b/action.yml index cbbe616..ff7bc85 100644 --- a/action.yml +++ b/action.yml @@ -18,6 +18,10 @@ inputs: description: "Run Python formatting" required: false default: "false" + python_docstrings: + description: "Run Python docstring formatting" + required: false + default: "false" markdown: description: "Run Markdown formatting (deprecated in favor of prettier)" required: false @@ -161,14 +165,9 @@ runs: ruff format \ --line-length 120 \ . || true - docformatter \ - --wrap-summaries 120 \ - --wrap-descriptions 120 \ - --pre-summary-newline \ - --close-quotes-on-newline \ - --in-place \ - --recursive \ - . + if [ "${{ inputs.python_docstrings }}" = "true" ]; then + ultralytics-actions-format-python-docstrings . || true + fi shell: bash continue-on-error: true diff --git a/actions/__init__.py b/actions/__init__.py index edb73fa..b0fa262 100644 --- a/actions/__init__.py +++ b/actions/__init__.py @@ -18,6 +18,7 @@ # │ ├── scan_prs.py # │ ├── summarize_pr.py # │ ├── summarize_release.py +# │ ├── format_python_docstrings.py # │ ├── update_file_headers.py # │ └── update_markdown_code_blocks.py # └── tests/ diff --git a/actions/format_python_docstrings.py b/actions/format_python_docstrings.py new file mode 100644 index 0000000..e0fe5df --- /dev/null +++ b/actions/format_python_docstrings.py @@ -0,0 +1,511 @@ +# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license + +from __future__ import annotations + +import ast +import re +import sys +import time +from pathlib import Path + +SECTIONS = ("Args", "Attributes", "Methods", "Returns", "Yields", "Raises", "Example", "Notes", "References") +LIST_RX = re.compile(r"""^(\s*)(?:[-*•]\s+|(?:\d+|[A-Za-z]+)[\.\)]\s+)""") +TABLE_RX = re.compile(r"^\s*\|.*\|\s*$") +TABLE_RULE_RX = re.compile(r"^\s*[:\-\|\s]{3,}$") +TREE_CHARS = ("└", "├", "│", "─") + + +def wrap_words(words: list[str], width: int, indent: int, min_words_per_line: int = 1) -> list[str]: + """Wrap words to width with indent; optionally avoid very short orphan lines.""" + pad = " " * indent + if not words: + return [] + lines: list[list[str]] = [] + cur: list[str] = [] + cur_len = indent + for w in words: + need = len(w) + (1 if cur else 0) + if cur and cur_len + need > width: + lines.append(cur) + cur, cur_len = [w], indent + len(w) + else: + cur.append(w) + cur_len += need + if cur: + lines.append(cur) + + # Rebalance to avoid too-short continuation lines when requested + if min_words_per_line > 1: + i = 1 + while i < len(lines): + if len(lines[i]) < min_words_per_line and len(lines[i - 1]) > 1: + donor = lines[i - 1][-1] + this_len = len(pad) + sum(len(w) for w in lines[i]) + (len(lines[i]) - 1) + if this_len + (1 if lines[i] else 0) + len(donor) <= width: + lines[i - 1].pop() + lines[i].insert(0, donor) + if i - 1 > 0 and len(lines[i - 1]) == 1: + i -= 1 + continue + i += 1 + + return [pad + " ".join(line) for line in lines] + + +def wrap_para(text: str, width: int, indent: int, min_words_per_line: int = 1) -> list[str]: + """Wrap a paragraph string; orphan control via min_words_per_line.""" + text = text.strip() + if not text: + return [] + return wrap_words(text.split(), width, indent, min_words_per_line) + + +def wrap_hanging(head: str, desc: str, width: int, cont_indent: int) -> list[str]: + """Wrap 'head + desc' with hanging indent; ensure first continuation has ≥2 words.""" + room = width - len(head) + words = desc.split() + if not words: + return [head.rstrip()] + + take, used = [], 0 + for w in words: + need = len(w) + (1 if take else 0) + if used + need <= room: + take.append(w) + used += need + else: + break + + out: list[str] = [] + if take: + out.append(head + " ".join(take)) + rest = words[len(take) :] + else: + out.append(head.rstrip()) + rest = words + + out.extend(wrap_words(rest, width, cont_indent, min_words_per_line=2)) + return out + + +def is_list_item(s: str) -> bool: + """True if s looks like a bullet/numbered list item.""" + return bool(LIST_RX.match(s.lstrip())) + + +def is_fence_line(s: str) -> bool: + t = s.lstrip() + return t.startswith("```") + + +def is_table_like(s: str) -> bool: + return bool(TABLE_RX.match(s)) or bool(TABLE_RULE_RX.match(s)) + + +def is_tree_like(s: str) -> bool: + return any(ch in s for ch in TREE_CHARS) + + +def is_indented_block_line(s: str) -> bool: + # Treat lines with >=8 leading spaces or any tab as preformatted + return bool(s.startswith(" ")) or s.startswith("\t") + + +def header_name(line: str) -> str | None: + """Return canonical section header or None.""" + s = line.strip() + if not s.endswith(":") or len(s) <= 1: + return None + name = s[:-1].strip() + if name == "Examples": + name = "Example" + if name == "Note": + name = "Notes" + return name if name in SECTIONS else None + + +def add_header(lines: list[str], indent: int, title: str, opener_line: str) -> None: + """Append a section header; no blank before first header, exactly one before subsequent ones.""" + while lines and lines[-1] == "": + lines.pop() + if lines and lines[-1] != opener_line: + lines.append("") + lines.append(" " * indent + f"{title}:") + + +def emit_paragraphs( + src: list[str], width: int, indent: int, list_indent: int | None = None, orphan_min: int = 1 +) -> list[str]: + """Wrap normal text; preserve lists, fenced code, tables, ASCII trees, and deeply-indented blocks.""" + out: list[str] = [] + buf: list[str] = [] + in_fence = False + + def flush(): + nonlocal buf + if buf: + out.extend(wrap_para(" ".join(x.strip() for x in buf), width, indent, min_words_per_line=orphan_min)) + buf = [] + + for raw in src: + s = raw.rstrip("\n") + stripped = s.strip() + + # blank line + if not stripped: + flush() + out.append("") + continue + + # fence start/stop + if is_fence_line(s): + flush() + out.append(s.rstrip()) + in_fence = not in_fence + continue + + if in_fence or is_table_like(s) or is_tree_like(s) or is_indented_block_line(s): + flush() + out.append(s.rstrip()) + continue + + if is_list_item(s): + flush() + out.append((" " * list_indent + stripped) if list_indent is not None else s.rstrip()) + continue + + buf.append(s) + flush() + while out and out[-1] == "": + out.pop() + return out + + +def parse_sections(text: str) -> dict[str, list[str]]: + """Parse Google-style docstring into sections.""" + parts = {k: [] for k in ("summary", "description", *SECTIONS)} + cur = "summary" + for raw in text.splitlines(): + line = raw.rstrip("\n") + h = header_name(line) + if h: + cur = h + continue + if not line.strip(): + if cur == "summary" and parts["summary"]: + cur = "description" + if parts[cur]: + parts[cur].append("") + continue + parts[cur].append(line) + return parts + + +def looks_like_param(s: str) -> bool: + """Heuristic: 'name:' without being a list item.""" + if is_list_item(s) or ":" not in s: + return False + head = s.split(":", 1)[0].strip() + return bool(head) + + +def iter_items(lines: list[str]) -> list[list[str]]: + """Group lines into logical items separated by next param.""" + items, i, n = [], 0, len(lines) + while i < n: + while i < n and not lines[i].strip(): + i += 1 + if i >= n: + break + item = [lines[i]] + i += 1 + while i < n: + st = lines[i].strip() + if st and looks_like_param(st): + break + item.append(lines[i]) + i += 1 + items.append(item) + return items + + +def format_structured_block(lines: list[str], width: int, base: int) -> list[str]: + """Format Args/Returns/etc.; continuation at base+4, lists at base+8. Preserve missing colons.""" + out: list[str] = [] + cont, lst = base + 4, base + 8 + for item in iter_items(lines): + first = item[0].strip() + name, desc = ([*first.split(":", 1), ""])[:2] + name, desc = name.strip(), desc.strip() + had_colon = ":" in first + + if not name or (" " in name and "(" not in name and ")" not in name): + out.extend(emit_paragraphs(item, width, cont, lst, orphan_min=2)) + continue + + head = " " * cont + (f"{name}: " if (desc or had_colon) else name) + out.extend(wrap_hanging(head, desc, width, cont + 4)) + + tail = item[1:] + if tail: + body = emit_paragraphs(tail, width, cont + 4, lst, orphan_min=2) + if body: + out.extend(body) + return out + + +def detect_opener(original_literal: str) -> tuple[str, str, bool]: + """Return (prefix, quotes, inline_hint) from the original string token safely.""" + s = original_literal.lstrip() + i = 0 + while i < len(s) and s[i] in "rRuUbBfF": + i += 1 + quotes = '"""' + if i + 3 <= len(s) and s[i : i + 3] in ('"""', "'''"): + quotes = s[i : i + 3] + keep = "".join(ch for ch in s[:i] if ch in "rRuU") + j = i + len(quotes) + inline_hint = j < len(s) and s[j : j + 1] not in {"", "\n", "\r"} + return keep, quotes, inline_hint + + +def format_google(text: str, indent: int, width: int, quotes: str, prefix: str, inline_first_line: bool) -> str: + """Format multi-line Google-style docstring with given quotes/prefix.""" + p = parse_sections(text) + opener = prefix + quotes + out = [opener] + if p["summary"]: + lines = emit_paragraphs(p["summary"], width, indent, list_indent=indent, orphan_min=1) + if inline_first_line and lines: + pad = " " * indent + first = lines[0][len(pad) :] if lines[0].startswith(pad) else lines[0].lstrip() + out[0] = opener + first + out.extend(lines[1:]) + else: + out.extend(lines) + if any(x.strip() for x in p["description"]): + out.append("") + out.extend(emit_paragraphs(p["description"], width, indent, list_indent=indent, orphan_min=1)) + for sec in ("Args", "Attributes", "Methods", "Returns", "Yields", "Raises"): + if any(x.strip() for x in p[sec]): + add_header(out, indent, sec, opener) + out.extend(format_structured_block(p[sec], width, indent)) + for sec in ("Example", "Notes", "References"): + if any(x.strip() for x in p[sec]): + title = "Examples" if sec == "Example" else sec + add_header(out, indent, title, opener) + out.extend(x.rstrip() for x in p[sec]) + while out and out[-1] == "": + out.pop() + out.append(" " * indent + quotes) + return "\n".join(out) + + +def format_docstring( + content: str, + indent: int, + width: int, + quotes: str, + prefix: str, + inline_hint: bool, + preserve_inline: bool = True, +) -> str: + """Single-line if short/sectionless/no-lists; else Google-style. Preserve quotes/prefix.""" + if not content or not content.strip(): + return f"{prefix}{quotes}{quotes}" + text = content.strip() + has_section = any(f"{s}:" in text for s in (*SECTIONS, "Examples")) + has_list = any(is_list_item(l) for l in text.splitlines()) + single_ok = ( + ("\n" not in text) + and not has_section + and not has_list + and (indent + len(prefix) + len(quotes) * 2 + len(text) <= width) + ) + if single_ok: + words = text.split() + if words and not (words[0].startswith(("http://", "https://")) or words[0][0].isupper()): + words[0] = words[0][0].upper() + words[0][1:] + out = " ".join(words) + if out and out[-1] not in ".!?": + out += "." + return f"{prefix}{quotes}{out}{quotes}" + return format_google(text, indent, width, quotes, prefix, inline_first_line=(preserve_inline and inline_hint)) + + +class Visitor(ast.NodeVisitor): + """Collect docstring replacements for classes and functions.""" + + def __init__(self, src: list[str], width: int = 120, preserve_inline: bool = True): + """Init with source lines, target width, and inline preservation flag.""" + self.src, self.width, self.repl, self.preserve_inline = src, width, [], preserve_inline + + def visit_Module(self, node): + """Skip module docstring; visit children.""" + self.generic_visit(node) + + def visit_ClassDef(self, node): + self._handle(node) + self.generic_visit(node) + + def visit_FunctionDef(self, node): + self._handle(node) + self.generic_visit(node) + + def visit_AsyncFunctionDef(self, node): + self._handle(node) + self.generic_visit(node) + + def _handle(self, node): + """If first stmt is a string expr, schedule replacement.""" + try: + doc = ast.get_docstring(node, clean=False) + if not doc or not node.body or not isinstance(node.body[0], ast.Expr): + return + s = node.body[0].value + if not (isinstance(s, ast.Constant) and isinstance(s.value, str)): + return + sl, el = node.body[0].lineno - 1, node.body[0].end_lineno - 1 + sc, ec = node.body[0].col_offset, node.body[0].end_col_offset + if sl < 0 or el >= len(self.src): + return + original = ( + self.src[sl][sc:ec] + if sl == el + else "\n".join([self.src[sl][sc:], *self.src[sl + 1 : el], self.src[el][:ec]]) + ) + prefix, quotes, inline_hint = detect_opener(original) + formatted = format_docstring(doc, sc, self.width, quotes, prefix, inline_hint, self.preserve_inline) + if formatted.strip() != original.strip(): + self.repl.append((sl, el, sc, ec, formatted)) + except Exception: + return + + +def format_python_file(text: str, width: int = 120, preserve_inline: bool = True) -> str: + """Return source with reformatted docstrings; on failure, return original.""" + if not text.strip(): + return text + try: + tree = ast.parse(text) + except SyntaxError: + return text + src = text.splitlines() + v = Visitor(src, width, preserve_inline=preserve_inline) + try: + v.visit(tree) + except Exception: + return text + for sl, el, sc, ec, rep in reversed(v.repl): + try: + if sl == el: + src[sl] = src[sl][:sc] + rep + src[sl][ec:] + else: + nl = rep.splitlines() + nl[0] = src[sl][:sc] + nl[0] + nl[-1] += src[el][ec:] + src[sl : el + 1] = nl + except Exception: + continue + return "\n".join(src) + + +def preserve_trailing_newlines(original: str, formatted: str) -> str: + """Preserve the original trailing newline count.""" + o = len(original) - len(original.rstrip("\n")) + f = len(formatted) - len(formatted.rstrip("\n")) + return formatted if o == f else formatted.rstrip("\n") + ("\n" * o) + + +def iter_py_files(paths: list[Path]) -> list[Path]: + """Expand input paths to sorted unique *.py files.""" + out: list[Path] = [] + for p in paths: + if p.is_dir(): + out.extend(sorted(p.rglob("*.py"))) + elif p.is_file() and p.suffix == ".py": + out.append(p) + seen, uniq = set(), [] + for f in out: + if f not in seen: + seen.add(f) + uniq.append(f) + return uniq + + +def process_file(path: Path, width: int = 120, check: bool = False, preserve_inline: bool = True) -> bool: + """Process one file; True if unchanged/success, False if changed.""" + if path.suffix != ".py": + return True + try: + orig = path.read_text(encoding="utf-8") + fmt = preserve_trailing_newlines(orig, format_python_file(orig, width, preserve_inline=preserve_inline)) + if check: + if orig != fmt: + print(f" {path}") + return False + return True + if orig != fmt: + path.write_text(fmt, encoding="utf-8") + print(f" {path}") + return False + return True + except Exception as e: + print(f" Error: {path}: {e}") + return True + + +def parse_cli(argv: list[str]) -> tuple[list[Path], int, bool, bool]: + """Minimal argv parser: (paths, width, check, preserve_inline).""" + width, check, paths, preserve_inline = 120, False, [], True + for a in argv: + if a == "--check": + check = True + elif a == "--no-preserve-inline": + preserve_inline = False + elif a.startswith("--line-width="): + try: + width = int(a.split("=", 1)[1]) + except ValueError: + pass + else: + paths.append(Path(a)) + return paths, width, check, preserve_inline + + +def main() -> None: + """CLI entry point.""" + args = sys.argv[1:] + if not args: + print( + "Usage: format_python_docstrings.py [--check] [--no-preserve-inline] [--line-width=120] " + ) + return + paths, width, check, preserve_inline = parse_cli(args) + files = iter_py_files(paths) + if not files: + print("No Python files found") + return + + t0 = time.time() + print(f"{'Checking' if check else 'Formatting'} {len(files)} file{'s' if len(files) != 1 else ''}") + changed = sum(not process_file(f, width, check, preserve_inline=preserve_inline) for f in files) + + dur = time.time() - t0 + if changed: + verb = "would be reformatted" if check else "reformatted" + unchanged = len(files) - changed + parts = [] + if changed: + parts.append(f"{changed} file{'s' if changed != 1 else ''} {verb}") + if unchanged: + parts.append(f"{unchanged} file{'s' if unchanged != 1 else ''} left unchanged") + print(f"{', '.join(parts)} ({dur:.1f}s)") + if check: + sys.exit(1) + else: + print(f"{len(files)} file{'s' if len(files) != 1 else ''} left unchanged ({dur:.1f}s)") + + +if __name__ == "__main__": + main() diff --git a/actions/update_markdown_code_blocks.py b/actions/update_markdown_code_blocks.py index 2e80971..0bfffc4 100644 --- a/actions/update_markdown_code_blocks.py +++ b/actions/update_markdown_code_blocks.py @@ -38,7 +38,7 @@ def add_indentation(code_block, num_spaces): def format_code_with_ruff(temp_dir): - """Formats Python code files in the specified directory using ruff linter and docformatter tools.""" + """Formats Python code files in the specified directory using ruff and Python docstring formatter.""" if not next(Path(temp_dir).rglob("*.py"), None): return @@ -81,23 +81,17 @@ def format_code_with_ruff(temp_dir): print(f"ERROR running ruff check ❌ {e}") try: - # Run docformatter + # Run Ultralytics Python docstring formatter subprocess.run( [ - "docformatter", - "--wrap-summaries=120", - "--wrap-descriptions=120", - "--pre-summary-newline", - "--close-quotes-on-newline", - "--in-place", - "--recursive", + "ultralytics-actions-format-python-docstrings", str(temp_dir), ], check=True, ) - print("Completed docformatter ✅") + print("Completed Python docstring formatting ✅") except Exception as e: - print(f"ERROR running docformatter ❌ {e}") + print(f"ERROR running Python docstring formatter ❌ {e}") def format_bash_with_prettier(temp_dir): diff --git a/pyproject.toml b/pyproject.toml index e60f176..e3dbd5b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,6 @@ classifiers = [ dependencies = [ "requests>=2.28.1", "ruff>=0.9.1", - "docformatter>=1.7.5", ] [project.optional-dependencies] @@ -90,6 +89,7 @@ ultralytics-actions-summarize-pr = "actions.summarize_pr:main" ultralytics-actions-summarize-release = "actions.summarize_release:main" ultralytics-actions-update-markdown-code-blocks = "actions.update_markdown_code_blocks:main" ultralytics-actions-headers = "actions.update_file_headers:main" +ultralytics-actions-format-python-docstrings = "actions.format_python_docstrings:main" ultralytics-actions-info = "actions.utils:ultralytics_actions_info" [tool.setuptools] @@ -107,10 +107,3 @@ line-length = 120 [tool.ruff.format] docstring-code-format = true - -[tool.docformatter] -wrap-summaries = 120 -wrap-descriptions = 120 -pre-summary-newline = true -close-quotes-on-newline = true -in-place = true