main
py 162 lines 5.29 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Ensures all fields in a config dataclass have default values
4 and that each field has a docstring.
5 """
6
7 import ast
8 import inspect
9 import sys
10 from itertools import pairwise
11
12 import regex as re
13
14
15 def get_attr_docs(cls_node: ast.ClassDef) -> dict[str, str]:
16 """Get any docstrings placed after attribute assignments in a class body.
17
18 Adapted from https://davidism.com/attribute-docstrings/
19 https://davidism.com/mit-license/
20 """
21 out = {}
22
23 # Consider each pair of nodes.
24 for a, b in pairwise(cls_node.body):
25 # Must be an assignment then a constant string.
26 if (
27 not isinstance(a, (ast.Assign, ast.AnnAssign))
28 or not isinstance(b, ast.Expr)
29 or not isinstance(b.value, ast.Constant)
30 or not isinstance(b.value.value, str)
31 ):
32 continue
33
34 doc = inspect.cleandoc(b.value.value)
35
36 # An assignment can have multiple targets (a = b = v), but an
37 # annotated assignment only has one target.
38 targets = a.targets if isinstance(a, ast.Assign) else [a.target]
39
40 for target in targets:
41 # Must be assigning to a plain name.
42 if not isinstance(target, ast.Name):
43 continue
44
45 out[target.id] = doc
46
47 return out
48
49
50 class ConfigValidator(ast.NodeVisitor):
51 def __init__(self): ...
52
53 def visit_ClassDef(self, node):
54 # Validate classes with a @config decorator
55 decorators = set()
56 for decorator in node.decorator_list:
57 if isinstance(decorator, ast.Call):
58 decorator = decorator.func
59 if isinstance(decorator, ast.Name) and decorator.id == "config":
60 decorators.add(decorator.id)
61
62 if decorators == {"config"}:
63 validate_class(node)
64 elif "config" in decorators:
65 fail(f"config decorator for {node.name} should be used alone", node)
66
67 self.generic_visit(node)
68
69
70 def validate_class(class_node: ast.ClassDef):
71 attr_docs = get_attr_docs(class_node)
72
73 for stmt in class_node.body:
74 # A field is defined as a class variable that has a type annotation.
75 if isinstance(stmt, ast.AnnAssign):
76 # Skip ClassVar and InitVar
77 # see https://docs.python.org/3/library/dataclasses.html#class-variables
78 # and https://docs.python.org/3/library/dataclasses.html#init-only-variables
79 if (
80 isinstance(stmt.annotation, ast.Subscript)
81 and isinstance(stmt.annotation.value, ast.Name)
82 and stmt.annotation.value.id in {"ClassVar", "InitVar"}
83 ):
84 continue
85
86 if isinstance(stmt.target, ast.Name):
87 field_name = stmt.target.id
88 if stmt.value is None:
89 fail(
90 f"Field '{field_name}' in {class_node.name} must have "
91 "a default value.",
92 stmt,
93 )
94
95 if field_name not in attr_docs:
96 fail(
97 f"Field '{field_name}' in {class_node.name} must have "
98 "a docstring.",
99 stmt,
100 )
101
102 if (
103 isinstance(stmt.annotation, ast.Subscript)
104 and isinstance(stmt.annotation.value, ast.Name)
105 and stmt.annotation.value.id == "Union"
106 and isinstance(stmt.annotation.slice, ast.Tuple)
107 ):
108 args = stmt.annotation.slice.elts
109 literal_args = [
110 arg
111 for arg in args
112 if isinstance(arg, ast.Subscript)
113 and isinstance(arg.value, ast.Name)
114 and arg.value.id == "Literal"
115 ]
116 if len(literal_args) > 1:
117 fail(
118 f"Field '{field_name}' in {class_node.name} must "
119 "use a single "
120 "Literal type. Please use 'Literal[Literal1, "
121 "Literal2]' instead of 'Union[Literal1, Literal2]'"
122 ".",
123 stmt,
124 )
125
126
127 def validate_ast(tree: ast.stmt):
128 ConfigValidator().visit(tree)
129
130
131 def validate_file(file_path: str):
132 try:
133 print(f"Validating {file_path} config dataclasses ", end="")
134 with open(file_path, encoding="utf-8") as f:
135 source = f.read()
136
137 tree = ast.parse(source, filename=file_path)
138 validate_ast(tree)
139 except ValueError as e:
140 print(e)
141 raise SystemExit(1) from e
142 else:
143 print("✅")
144
145
146 def fail(message: str, node: ast.stmt):
147 raise ValueError(f"❌ line({node.lineno}): {message}")
148
149
150 def main():
151 for filename in sys.argv[1:]:
152 # Only run for Python files in vllm/ or tests/
153 if not re.match(r"^(vllm|tests)/.*\.py$", filename):
154 continue
155 # Only run if the file contains @config
156 with open(filename, encoding="utf-8") as f:
157 if "@config" in f.read():
158 validate_file(filename)
159
160
161 if __name__ == "__main__":
162 main()