1
0
mirror of https://github.com/huggingface/diffusers.git synced 2026-10-23 09:09:01 +03:00
Files
diffusers/utils/modular_auto_docstring.py
YiYi Xu 4d89a88748 [modular] detect stale auto docstrings in CI + regenerate current ones (#14241)
* Regenerate stale modular auto docstrings

Mechanical output of `python utils/modular_auto_docstring.py
src/diffusers/modular_pipelines --fix_and_overwrite` on current main.
These docstrings drifted because the CI check only verifies a docstring
exists, not that its content matches the current block declarations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make auto docstring check detect stale content, not just missing docstrings

Check mode now regenerates each # auto_docstring docstring the same way
--fix_and_overwrite does (generate -> insert -> ruff/doc-builder format,
on a temp copy) and compares with the checked-in file, reporting the
stale classes. Works like check_copies: forgetting to run the fixer now
fails CI on the PR that caused the drift, including when the causing
change is in another file (leaf block declarations, templates, specs).

The check now imports diffusers to evaluate doc properties, so the
check_auto_docs CI job moves to the torch container with the test job's
dependency install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Sayak Paul <spsayakpaul@gmail.com>
2026-07-22 09:05:54 -10:00

404 lines
15 KiB
Python

# coding=utf-8
# Copyright 2025 The HuggingFace Inc. team.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""
Auto Docstring Generator for Modular Pipeline Blocks
This script scans Python files for classes that have `# auto_docstring` comment above them
and inserts/updates the docstring from the class's `doc` property.
Run from the root of the repo:
python utils/modular_auto_docstring.py [path] [--fix_and_overwrite]
Examples:
# Check mode (will error if a marked class's docstring is missing or out of date — the check regenerates
# each docstring the same way --fix_and_overwrite would and compares the result with the file)
python utils/modular_auto_docstring.py
# Check specific directory
python utils/modular_auto_docstring.py src/diffusers/modular_pipelines/
# Fix and overwrite the docstrings
python utils/modular_auto_docstring.py --fix_and_overwrite
Usage in code:
# auto_docstring
class QwenImageAutoVaeEncoderStep(AutoPipelineBlocks):
# docstring will be automatically inserted here
@property
def doc(self):
return "Your docstring content..."
"""
import argparse
import ast
import glob
import importlib
import os
import re
import subprocess
import sys
import tempfile
# All paths are set with the intent you should run this script from the root of the repo
DIFFUSERS_PATH = "src/diffusers"
REPO_PATH = "."
# Pattern to match the auto_docstring comment
AUTO_DOCSTRING_PATTERN = re.compile(r"^\s*#\s*auto_docstring\s*$")
def setup_diffusers_import():
"""Setup import path to use the local diffusers module."""
src_path = os.path.join(REPO_PATH, "src")
if src_path not in sys.path:
sys.path.insert(0, src_path)
def get_module_from_filepath(filepath: str) -> str:
"""Convert a filepath to a module name."""
filepath = os.path.normpath(filepath)
if filepath.startswith("src" + os.sep):
filepath = filepath[4:]
if filepath.endswith(".py"):
filepath = filepath[:-3]
module_name = filepath.replace(os.sep, ".")
return module_name
def load_module(filepath: str):
"""Load a module from filepath."""
setup_diffusers_import()
module_name = get_module_from_filepath(filepath)
try:
module = importlib.import_module(module_name)
return module
except Exception as e:
print(f"Warning: Could not import module {module_name}: {e}")
return None
def get_doc_from_class(module, class_name: str) -> str:
"""Get the doc property from an instantiated class."""
if module is None:
return None
cls = getattr(module, class_name, None)
if cls is None:
return None
try:
instance = cls()
if hasattr(instance, "doc"):
return instance.doc
except Exception as e:
print(f"Warning: Could not instantiate {class_name}: {e}")
return None
def find_auto_docstring_classes(filepath: str) -> list:
"""
Find all classes in a file that have # auto_docstring comment above them.
Returns list of (class_name, class_line_number, has_existing_docstring, docstring_end_line)
"""
with open(filepath, "r", encoding="utf-8", newline="\n") as f:
lines = f.readlines()
# Parse AST to find class locations and their docstrings
content = "".join(lines)
try:
tree = ast.parse(content)
except SyntaxError as e:
print(f"Syntax error in {filepath}: {e}")
return []
# Build a map of class_name -> (class_line, has_docstring, docstring_end_line)
class_info = {}
for node in ast.walk(tree):
if isinstance(node, ast.ClassDef):
has_docstring = False
docstring_end_line = node.lineno # default to class line
if node.body and isinstance(node.body[0], ast.Expr):
first_stmt = node.body[0]
if isinstance(first_stmt.value, ast.Constant) and isinstance(first_stmt.value.value, str):
has_docstring = True
docstring_end_line = first_stmt.end_lineno or first_stmt.lineno
class_info[node.name] = (node.lineno, has_docstring, docstring_end_line)
# Now scan for # auto_docstring comments
classes_to_update = []
for i, line in enumerate(lines):
if AUTO_DOCSTRING_PATTERN.match(line):
# Found the marker, look for class definition on next non-empty, non-comment line
j = i + 1
while j < len(lines):
next_line = lines[j].strip()
if next_line and not next_line.startswith("#"):
break
j += 1
if j < len(lines) and lines[j].strip().startswith("class "):
# Extract class name
match = re.match(r"class\s+(\w+)", lines[j].strip())
if match:
class_name = match.group(1)
if class_name in class_info:
class_line, has_docstring, docstring_end_line = class_info[class_name]
classes_to_update.append((class_name, class_line, has_docstring, docstring_end_line))
return classes_to_update
def strip_class_name_line(doc: str, class_name: str) -> str:
"""Remove the 'class ClassName' line from the doc if present."""
lines = doc.strip().split("\n")
if lines and lines[0].strip() == f"class {class_name}":
# Remove the class line and any blank line following it
lines = lines[1:]
while lines and not lines[0].strip():
lines = lines[1:]
return "\n".join(lines)
def format_docstring(doc: str, indent: str = " ") -> str:
"""Format a doc string as a properly indented docstring."""
lines = doc.strip().split("\n")
if len(lines) == 1:
return f'{indent}"""{lines[0]}"""\n'
else:
result = [f'{indent}"""\n']
for line in lines:
if line.strip():
result.append(f"{indent}{line}\n")
else:
result.append("\n")
result.append(f'{indent}"""\n')
return "".join(result)
def run_ruff_format(filepath: str, quiet: bool = False):
"""Run ruff check --fix, ruff format, and doc-builder style on a file to ensure consistent formatting."""
try:
# First run ruff check --fix to fix any linting issues (including line length)
subprocess.run(
["ruff", "check", "--fix", filepath],
check=False, # Don't fail if there are unfixable issues
capture_output=True,
text=True,
)
# Then run ruff format for code formatting
subprocess.run(
["ruff", "format", filepath],
check=True,
capture_output=True,
text=True,
)
# Finally run doc-builder style for docstring formatting
subprocess.run(
["doc-builder", "style", filepath, "--max_len", "119"],
check=False, # Don't fail if doc-builder has issues
capture_output=True,
text=True,
)
if not quiet:
print(f"Formatted {filepath}")
except subprocess.CalledProcessError as e:
print(f"Warning: formatting failed for {filepath}: {e.stderr}")
except FileNotFoundError as e:
print(f"Warning: tool not found ({e}). Skipping formatting.")
except Exception as e:
print(f"Warning: unexpected error formatting {filepath}: {e}")
def format_content(content: str, filepath: str) -> str:
"""Run the same formatters fix mode uses on a temporary copy of `content` and return the result."""
fd, tmp_path = tempfile.mkstemp(suffix=".py", dir=os.path.dirname(filepath) or ".")
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
f.write(content)
run_ruff_format(tmp_path, quiet=True)
with open(tmp_path, "r", encoding="utf-8", newline="\n") as f:
return f.read()
finally:
os.remove(tmp_path)
def get_existing_docstring(lines: list, class_line: int, docstring_end_line: int) -> str:
"""Extract the existing docstring content from lines."""
# class_line is 1-indexed, docstring starts at class_line (0-indexed: class_line)
# and ends at docstring_end_line (1-indexed, inclusive)
docstring_lines = lines[class_line:docstring_end_line]
return "".join(docstring_lines)
def build_updated_lines(filepath: str, classes_to_update: list) -> tuple:
"""
Return the file's lines with every marked class's docstring regenerated (pre-formatting), plus the names of the
classes that were regenerated.
"""
module = load_module(filepath)
with open(filepath, "r", encoding="utf-8", newline="\n") as f:
lines = f.readlines()
# Process in reverse order to maintain line numbers
updated_names = []
for class_name, class_line, has_docstring, docstring_end_line in reversed(classes_to_update):
doc = get_doc_from_class(module, class_name)
if doc is None:
print(f"Warning: Could not get doc for {class_name} in {filepath}")
continue
# Remove the "class ClassName" line since it's redundant in a docstring
doc = strip_class_name_line(doc, class_name)
# Format the new docstring with 4-space indent
new_docstring = format_docstring(doc, " ")
if has_docstring:
# Replace existing docstring (line after class definition to docstring_end_line)
# class_line is 1-indexed, we want to replace from class_line+1 to docstring_end_line
lines = lines[:class_line] + [new_docstring] + lines[docstring_end_line:]
else:
# Insert new docstring right after class definition line
# class_line is 1-indexed, so lines[class_line-1] is the class line
# Insert at position class_line (which is right after the class line)
lines = lines[:class_line] + [new_docstring] + lines[class_line:]
updated_names.append(class_name)
return lines, updated_names
def _class_docstrings(content: str) -> dict:
"""Map class name -> raw docstring value for every class in `content`."""
tree = ast.parse(content)
return {
node.name: ast.get_docstring(node, clean=False) for node in ast.walk(tree) if isinstance(node, ast.ClassDef)
}
def process_file(filepath: str, overwrite: bool = False) -> list:
"""
Process a file and find/insert docstrings for # auto_docstring marked classes.
In fix mode, regenerates every marked docstring in place. In check mode, regenerates them on a temporary copy
through the same formatting pipeline and returns the classes whose checked-in docstring differs from the
regenerated one (missing docstrings included).
Returns list of classes that need updating.
"""
classes_to_update = find_auto_docstring_classes(filepath)
if not classes_to_update:
return []
lines, updated_names = build_updated_lines(filepath, classes_to_update)
if overwrite:
if updated_names:
with open(filepath, "w", encoding="utf-8", newline="\n") as f:
f.writelines(lines)
# Run ruff format to ensure consistent line wrapping
run_ruff_format(filepath)
for class_name in reversed(updated_names):
print(f"Updated docstring for {class_name} in {filepath}")
return [(filepath, cls_name, line) for cls_name, line, _, _ in classes_to_update]
# Check mode: regenerate through the same formatting pipeline and compare with the checked-in file
with open(filepath, "r", encoding="utf-8", newline="\n") as f:
current_content = f.read()
new_content = format_content("".join(lines), filepath)
if new_content == current_content:
return []
current_docs = _class_docstrings(current_content)
new_docs = _class_docstrings(new_content)
stale = [
(filepath, class_name, class_line)
for class_name, class_line, _, _ in classes_to_update
if current_docs.get(class_name) != new_docs.get(class_name)
]
if not stale:
# the regenerated file differs outside the marked docstrings (e.g. the file itself is not
# ruff/doc-builder clean); attribute it to the file so the mismatch is not silently ignored
stale = [(filepath, classes_to_update[0][0], classes_to_update[0][1])]
return stale
def check_auto_docstrings(path: str = None, overwrite: bool = False):
"""
Check all files for # auto_docstring markers and optionally fix them.
"""
if path is None:
path = DIFFUSERS_PATH
if os.path.isfile(path):
all_files = [path]
else:
all_files = glob.glob(os.path.join(path, "**/*.py"), recursive=True)
all_markers = []
for filepath in all_files:
markers = process_file(filepath, overwrite)
all_markers.extend(markers)
if not overwrite and len(all_markers) > 0:
message = "\n".join([f"- {f}: {cls} at line {line}" for f, cls, line in all_markers])
raise ValueError(
f"The following # auto_docstring docstrings are missing or out of date:\n{message}\n\n"
f"These docstrings are generated from the block declarations (which may live in other files), so a "
f"change elsewhere can make them stale. Run `python utils/modular_auto_docstring.py --fix_and_overwrite` "
f"from the root of the repo to regenerate them."
)
if overwrite and len(all_markers) > 0:
print(f"\nProcessed {len(all_markers)} docstring(s).")
elif not overwrite and len(all_markers) == 0:
print("All # auto_docstring markers have up-to-date docstrings.")
elif len(all_markers) == 0:
print("No # auto_docstring markers found.")
if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Check and fix # auto_docstring markers in modular pipeline blocks",
)
parser.add_argument("path", nargs="?", default=None, help="File or directory to process (default: src/diffusers)")
parser.add_argument(
"--fix_and_overwrite",
action="store_true",
help="Whether to fix the docstrings by inserting them from doc property.",
)
args = parser.parse_args()
check_auto_docstrings(args.path, args.fix_and_overwrite)