mirror of
https://github.com/huggingface/diffusers.git
synced 2026-10-24 20:59:27 +03:00
* 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>
404 lines
15 KiB
Python
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)
|