Skip to content

Commit eed1713

Browse files
committed
adds generate.py file
1 parent 4045bde commit eed1713

File tree

1 file changed

+362
-0
lines changed

1 file changed

+362
-0
lines changed

scripts/microgenerator/generate.py

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright 2025 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
#
16+
17+
"""
18+
A dual-purpose module for Python code analysis and BigQuery client generation.
19+
20+
When run as a script, it generates the BigQueryClient source code.
21+
When imported, it provides utility functions for parsing and exploring
22+
any Python codebase using the `ast` module.
23+
"""
24+
25+
import ast
26+
import os
27+
import argparse
28+
import glob
29+
from collections import defaultdict
30+
from typing import List, Dict, Any, Iterator
31+
32+
import utils
33+
34+
# =============================================================================
35+
# Section 1: Generic AST Analysis Utilities
36+
# =============================================================================
37+
38+
class CodeAnalyzer(ast.NodeVisitor):
39+
"""
40+
A node visitor to traverse an AST and extract structured information
41+
about classes, methods, and their arguments.
42+
"""
43+
44+
def __init__(self):
45+
self.structure: List[Dict[str, Any]] = []
46+
self._current_class_info: Dict[str, Any] | None = None
47+
self._is_in_method: bool = False
48+
49+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
50+
"""Visits a class definition node."""
51+
class_info = {
52+
"class_name": node.name,
53+
"methods": [],
54+
"attributes": [],
55+
}
56+
self.structure.append(class_info)
57+
self._current_class_info = class_info
58+
self.generic_visit(node)
59+
self._current_class_info = None
60+
61+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
62+
"""Visits a function/method definition node."""
63+
if self._current_class_info: # This is a method
64+
method_info = {
65+
"method_name": node.name,
66+
"args": [arg.arg for arg in node.args.args],
67+
}
68+
self._current_class_info["methods"].append(method_info)
69+
70+
# Visit nodes inside the method to find instance attributes
71+
self._is_in_method = True
72+
self.generic_visit(node)
73+
self._is_in_method = False
74+
75+
def _add_attribute(self, attr_name: str):
76+
"""Adds a unique attribute to the current class context."""
77+
if self._current_class_info:
78+
if attr_name not in self._current_class_info["attributes"]:
79+
self._current_class_info["attributes"].append(attr_name)
80+
81+
def visit_Assign(self, node: ast.Assign) -> None:
82+
"""Handles attribute assignments: `x = ...` and `self.x = ...`."""
83+
if self._current_class_info:
84+
for target in node.targets:
85+
# Instance attribute: self.x = ...
86+
if (
87+
isinstance(target, ast.Attribute)
88+
and isinstance(target.value, ast.Name)
89+
and target.value.id == "self"
90+
):
91+
self._add_attribute(target.attr)
92+
# Class attribute: x = ... (only if not inside a method)
93+
elif isinstance(target, ast.Name) and not self._is_in_method:
94+
self._add_attribute(target.id)
95+
self.generic_visit(node)
96+
97+
def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
98+
"""Handles annotated assignments: `x: int = ...` and `self.x: int = ...`."""
99+
if self._current_class_info:
100+
target = node.target
101+
# Instance attribute: self.x: int = ...
102+
if (
103+
isinstance(target, ast.Attribute)
104+
and isinstance(target.value, ast.Name)
105+
and target.value.id == "self"
106+
):
107+
self._add_attribute(target.attr)
108+
# Class attribute: x: int = ... (only if not inside a method)
109+
elif isinstance(target, ast.Name) and not self._is_in_method:
110+
self._add_attribute(target.id)
111+
self.generic_visit(node)
112+
113+
114+
def parse_code(code: str) -> List[Dict[str, Any]]:
115+
"""
116+
Parses a string of Python code into a structured list of classes.
117+
118+
Args:
119+
code: A string containing Python code.
120+
121+
Returns:
122+
A list of dictionaries, where each dictionary represents a class.
123+
"""
124+
tree = ast.parse(code)
125+
analyzer = CodeAnalyzer()
126+
analyzer.visit(tree)
127+
return analyzer.structure
128+
129+
130+
def parse_file(file_path: str) -> List[Dict[str, Any]]:
131+
"""
132+
Parses a Python file into a structured list of classes.
133+
134+
Args:
135+
file_path: The absolute path to the Python file.
136+
137+
Returns:
138+
A list of dictionaries representing the classes in the file.
139+
"""
140+
with open(file_path, "r", encoding="utf-8") as source:
141+
code = source.read()
142+
return parse_code(code)
143+
144+
145+
def list_classes(path: str) -> List[str]:
146+
"""Lists all classes in a given Python file or directory."""
147+
class_names = []
148+
if os.path.isfile(path) and path.endswith(".py"):
149+
structure = parse_file(path)
150+
for class_info in structure:
151+
class_names.append(class_info["class_name"])
152+
elif os.path.isdir(path):
153+
for file_path in utils.walk_codebase(path):
154+
structure = parse_file(file_path)
155+
for class_info in structure:
156+
class_names.append(
157+
f"{class_info['class_name']} (in {os.path.basename(file_path)})"
158+
)
159+
return sorted(class_names)
160+
161+
162+
def list_classes_and_methods(path: str) -> Dict[str, List[str]]:
163+
"""Lists all classes and their methods in a given Python file or directory."""
164+
results = defaultdict(list)
165+
166+
def process_structure(structure, file_name=None):
167+
for class_info in structure:
168+
key = class_info["class_name"]
169+
if file_name:
170+
key = f"{key} (in {file_name})"
171+
172+
results[key] = sorted([m["method_name"] for m in class_info["methods"]])
173+
174+
if os.path.isfile(path) and path.endswith(".py"):
175+
process_structure(parse_file(path))
176+
elif os.path.isdir(path):
177+
for file_path in utils.walk_codebase(path):
178+
process_structure(
179+
parse_file(file_path), file_name=os.path.basename(file_path)
180+
)
181+
182+
return results
183+
184+
185+
def list_classes_methods_and_attributes(path: str) -> Dict[str, Dict[str, List[str]]]:
186+
"""Lists classes, methods, and attributes in a file or directory."""
187+
results = defaultdict(lambda: defaultdict(list))
188+
189+
def process_structure(structure, file_name=None):
190+
for class_info in structure:
191+
key = class_info["class_name"]
192+
if file_name:
193+
key = f"{key} (in {file_name})"
194+
195+
results[key]["attributes"] = sorted(class_info["attributes"])
196+
results[key]["methods"] = sorted(
197+
[m["method_name"] for m in class_info["methods"]]
198+
)
199+
200+
if os.path.isfile(path) and path.endswith(".py"):
201+
process_structure(parse_file(path))
202+
elif os.path.isdir(path):
203+
for file_path in utils.walk_codebase(path):
204+
process_structure(
205+
parse_file(file_path), file_name=os.path.basename(file_path)
206+
)
207+
208+
return results
209+
210+
211+
def list_classes_methods_attributes_and_arguments(
212+
path: str,
213+
) -> Dict[str, Dict[str, Any]]:
214+
"""Lists classes, methods, attributes, and arguments in a file or directory."""
215+
results = defaultdict(lambda: defaultdict(list))
216+
217+
def process_structure(structure, file_name=None):
218+
for class_info in structure:
219+
key = class_info["class_name"]
220+
if file_name:
221+
key = f"{key} (in {file_name})"
222+
223+
results[key]["attributes"] = sorted(class_info["attributes"])
224+
method_details = {}
225+
# Sort methods by name for consistent output
226+
for method in sorted(class_info["methods"], key=lambda m: m["method_name"]):
227+
method_details[method["method_name"]] = method["args"]
228+
results[key]["methods"] = method_details
229+
230+
if os.path.isfile(path) and path.endswith(".py"):
231+
process_structure(parse_file(path))
232+
elif os.path.isdir(path):
233+
for file_path in utils.walk_codebase(path):
234+
process_structure(
235+
parse_file(file_path), file_name=os.path.basename(file_path)
236+
)
237+
238+
return results
239+
240+
241+
# =============================================================================
242+
# Section 2: Generic Code Generation Logic
243+
# =============================================================================
244+
245+
def analyze_source_files(config: Dict[str, Any]) -> Dict[str, Any]:
246+
"""
247+
Analyzes source files as per the configuration to extract class and method info.
248+
249+
Args:
250+
config: The generator's configuration dictionary.
251+
252+
Returns:
253+
A dictionary containing the data needed for template rendering.
254+
"""
255+
parsed_data = defaultdict(dict)
256+
source_patterns = config.get("source_files", [])
257+
filter_rules = config.get("filter", {})
258+
class_filters = filter_rules.get("classes", {})
259+
method_filters = filter_rules.get("methods", {})
260+
261+
source_files = []
262+
for pattern in source_patterns:
263+
source_files.extend(glob.glob(pattern, recursive=True))
264+
265+
for file_path in source_files:
266+
structure = parse_file(file_path)
267+
268+
for class_info in structure:
269+
class_name = class_info["class_name"]
270+
# Apply class filters
271+
if class_filters.get("include_suffixes"):
272+
if not class_name.endswith(tuple(class_filters["include_suffixes"])):
273+
continue
274+
275+
parsed_data[class_name] # Ensure class is in dict
276+
277+
for method in class_info["methods"]:
278+
method_name = method["method_name"]
279+
# Apply method filters
280+
if method_filters.get("include_prefixes"):
281+
if not any(
282+
method_name.startswith(p)
283+
for p in method_filters["include_prefixes"]
284+
):
285+
continue
286+
if method_filters.get("exclude_prefixes"):
287+
if any(
288+
method_name.startswith(p)
289+
for p in method_filters["exclude_prefixes"]
290+
):
291+
continue
292+
parsed_data[class_name][method_name] = method["args"]
293+
return parsed_data
294+
295+
296+
def _format_args(method_args: List[str]) -> tuple[str, str]:
297+
"""Formats method arguments for use in creating a method definition and a method call."""
298+
args_for_def = ", ".join(method_args)
299+
args_for_call = ", ".join([f"{arg}={arg}" for arg in method_args if arg != "self"])
300+
return args_for_def, args_for_call
301+
302+
303+
def _format_class_name(method_name: str, suffix: str = "Request") -> str:
304+
"""Formats a class name from a method name."""
305+
return "".join(word.capitalize() for word in method_name.split("_")) + suffix
306+
307+
308+
def generate_code(config: Dict[str, Any], data: Dict[str, Any]) -> None:
309+
"""
310+
Generates source code files using Jinja2 templates.
311+
"""
312+
templates_config = config.get("templates", [])
313+
for item in templates_config:
314+
template_path = item["template"]
315+
output_path = item["output"]
316+
317+
print(f"Processing template: {template_path}.")
318+
319+
template = utils.load_template(template_path)
320+
methods_context = []
321+
for class_name, methods in data.items():
322+
for method_name, method_args in methods.items():
323+
args_for_def, args_for_call = _format_args(method_args)
324+
request_class_name = _format_class_name(method_name)
325+
methods_context.append(
326+
{
327+
"name": method_name,
328+
"class_name": class_name,
329+
"args_for_def": args_for_def,
330+
"args_for_call": args_for_call,
331+
"request_class_name": request_class_name,
332+
}
333+
)
334+
335+
print(f"Found {len(methods_context)} methods to generate.")
336+
337+
final_code = template.render(
338+
service_name=config.get("service_name"),
339+
methods=methods_context
340+
)
341+
342+
utils.write_code_to_file(output_path, final_code)
343+
344+
345+
# =============================================================================
346+
# Section 3: Main Execution
347+
# =============================================================================
348+
349+
if __name__ == "__main__":
350+
parser = argparse.ArgumentParser(
351+
description="A generic Python code generator for clients."
352+
)
353+
parser.add_argument(
354+
"config", help="Path to the YAML configuration file."
355+
)
356+
args = parser.parse_args()
357+
358+
config = utils.load_config(args.config)
359+
data = analyze_source_files(config)
360+
generate_code(config, data)
361+
362+
# TODO: Ensure blacken gets called on the generated source files as a final step.

0 commit comments

Comments
 (0)