Skip to content

Commit 07de4b5

Browse files
authored
A utility function that collects all grader information. (#58)
1 parent c9ae430 commit 07de4b5

2 files changed

Lines changed: 253 additions & 0 deletions

File tree

openjudge/utils/grader_info.py

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
"""This module provides a utility function for collecting the core information of all Grader classes."""
2+
3+
import ast
4+
import json
5+
import re
6+
import time
7+
from copy import deepcopy
8+
from pathlib import Path
9+
from queue import Queue
10+
from typing import Any, Dict, List
11+
12+
from loguru import logger
13+
14+
15+
class _MethodInfo:
16+
def __init__(self, signature: str = "", docstring: str = ""):
17+
# covers name, arguments, and maybe return type
18+
self.signature = signature or ""
19+
self.docstring = docstring or ""
20+
21+
def __str__(self):
22+
return json.dumps(self.__dict__, ensure_ascii=False)
23+
24+
25+
class _GraderInfo:
26+
"""A class that stores core information of a Grader class"""
27+
28+
def __init__(
29+
self,
30+
file_path: str = "",
31+
class_name: str = "",
32+
parent_class_names: list = None,
33+
init_method: _MethodInfo = None,
34+
aevaluate_method: _MethodInfo = None,
35+
):
36+
self.file_path = file_path or ""
37+
self.class_name = class_name or ""
38+
self.parent_class_names = parent_class_names or []
39+
self.init_method = init_method or _MethodInfo()
40+
self.aevaluate_method = aevaluate_method or _MethodInfo()
41+
42+
def __str__(self):
43+
d = deepcopy(self.__dict__)
44+
d["init_method"] = self.init_method.__dict__
45+
d["aevaluate_method"] = self.aevaluate_method.__dict__
46+
return json.dumps(d, ensure_ascii=False)
47+
48+
def __iter__(self):
49+
"""Customize dict(obj) result"""
50+
yield "file_path", self.file_path
51+
yield "class_name", self.class_name
52+
yield "parent_class_names", self.parent_class_names
53+
yield "init_method", self.init_method.__dict__
54+
yield "aevaluate_method", self.aevaluate_method.__dict__
55+
56+
57+
def get_all_grader_info() -> List[Dict[str, Any]]:
58+
"""Collect the information of all graders defined under the openjudge/graders folder."""
59+
t0 = time.time_ns()
60+
current_file_abs_path = Path(__file__).resolve()
61+
logger.info(f"grader_info.py path:{current_file_abs_path}")
62+
63+
defs_of_classes_having_parent = {}
64+
grader_root_folder = Path(current_file_abs_path.parent.parent, "graders")
65+
logger.info(f"grader root folder:{grader_root_folder}")
66+
unprocessed_folders = Queue()
67+
unprocessed_folders.put(grader_root_folder)
68+
while not unprocessed_folders.empty():
69+
folder = unprocessed_folders.get()
70+
for item in folder.iterdir():
71+
if item.is_dir():
72+
unprocessed_folders.put(item)
73+
elif item.stem == "__init__" or item.suffix != ".py":
74+
# use safe heuristics to reduce candidate count
75+
continue
76+
else:
77+
_get_defs_of_classes_having_parent(item, defs_of_classes_having_parent)
78+
79+
logger.info(f"classes having parent:{len(defs_of_classes_having_parent)}, {(time.time_ns() - t0)/1000000}ms")
80+
81+
all_grader_class_defs = _get_grader_class_def(defs_of_classes_having_parent)
82+
83+
t0 = time.time_ns()
84+
all_grader_info = []
85+
for _, (class_def, source_code) in all_grader_class_defs.items():
86+
grader_info = _parse_grader_class_def(class_def, source_code)
87+
all_grader_info.append(dict(grader_info))
88+
89+
logger.info(f"all grader info:{len(all_grader_info)}, {(time.time_ns() - t0)/1000000}ms")
90+
91+
return all_grader_info
92+
93+
94+
def _get_defs_of_classes_having_parent(py_file: Path, defs_of_classes_having_parent: Dict[ast.ClassDef, str]):
95+
"""Get the definitions and source codes of all classe that have parent class."""
96+
with open(py_file, "r", encoding="utf-8") as file:
97+
source_code = file.read()
98+
99+
# Parse the source code into an AST node
100+
tree = ast.parse(source_code)
101+
for node in ast.walk(tree):
102+
if isinstance(node, ast.ClassDef) and node.bases:
103+
parent_count = len(node.bases)
104+
if parent_count > 1 or node.bases[0].id != "ABC":
105+
defs_of_classes_having_parent[node] = source_code
106+
107+
108+
def _get_grader_class_def(defs_of_classes_having_parent: Dict[ast.ClassDef, str]):
109+
"""Get the definitions of Grader classes"""
110+
t0 = time.time_ns()
111+
# the base grader class as seed
112+
all_grader_class_defs = {"BaseGrader": None}
113+
found_grader = True
114+
while found_grader:
115+
found_grader = False
116+
known_defs = []
117+
new_defs = []
118+
119+
for class_def, source_code in defs_of_classes_having_parent.items():
120+
# Skip whose already processed
121+
if class_def.name in all_grader_class_defs:
122+
known_defs.append(class_def)
123+
continue
124+
125+
for parent in class_def.bases:
126+
if parent.id in all_grader_class_defs:
127+
all_grader_class_defs[class_def.name] = (class_def, source_code)
128+
new_defs.append(class_def)
129+
found_grader = True
130+
break
131+
132+
# reduce inputs for the next round, by removing those processed in this round
133+
for n in known_defs:
134+
defs_of_classes_having_parent.pop(n)
135+
for n in new_defs:
136+
defs_of_classes_having_parent.pop(n)
137+
138+
# remove the seed
139+
all_grader_class_defs.pop("BaseGrader")
140+
141+
logger.info(f"all grader class defs:{len(all_grader_class_defs)}, {(time.time_ns() - t0)/1000000}ms")
142+
return all_grader_class_defs
143+
144+
145+
_INIT_METHOD = "__init__"
146+
_AEVALUATE_METHOD = "aevaluate"
147+
_TARGET_METHODS = set()
148+
_TARGET_METHODS.add(_INIT_METHOD)
149+
_TARGET_METHODS.add(_AEVALUATE_METHOD)
150+
151+
_NEWLINE_OR_MULTI_SPACE_PATTERN = re.compile(r"(\n|\s\s+)")
152+
153+
154+
def _parse_grader_class_def(class_def: ast.ClassDef, source_code: str) -> _GraderInfo:
155+
"""Use ast util to extract core information from a Grader class,
156+
and put the information into a _GraderInfo object."""
157+
158+
init_method = _MethodInfo()
159+
aeval_method = _MethodInfo()
160+
# Find target methods within the class body
161+
for sub_node in class_def.body:
162+
if isinstance(sub_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
163+
method_name = sub_node.name
164+
if method_name not in _TARGET_METHODS:
165+
continue
166+
167+
segment = ast.get_source_segment(source_code, sub_node)
168+
segment = _NEWLINE_OR_MULTI_SPACE_PATTERN.sub(" ", segment.strip())
169+
segment = segment.replace(") :", "):").replace(") ->", ")->").replace(" :", ":")
170+
171+
# Method head ends in two ways: w/ or w/o return type annocation.
172+
# Figure it out.
173+
idx0 = segment.find("):")
174+
idx1 = segment.find(")->")
175+
if idx1 > 0:
176+
idx1 = segment.find(":", idx1)
177+
178+
if idx0 > 0 and idx1 > 0:
179+
if idx0 < idx1:
180+
idx = idx0 + 2
181+
else:
182+
idx = idx1 + 1
183+
elif idx0 > 0:
184+
idx = idx0 + 2
185+
elif idx1 > 0:
186+
idx = idx1 + 1
187+
else:
188+
idx = -1
189+
190+
# def foo(...) -> type:
191+
# def foo(...):
192+
if idx > 0:
193+
signature = segment[0:idx]
194+
else:
195+
signature = "SIGNATURE_NOT_FOUND"
196+
197+
docstring = ast.get_docstring(sub_node)
198+
if method_name == _INIT_METHOD:
199+
init_method.signature = signature
200+
init_method.docstring = docstring
201+
elif method_name == _AEVALUATE_METHOD:
202+
aeval_method.signature = signature
203+
aeval_method.docstring = docstring
204+
205+
grader_info_obj = _GraderInfo(
206+
class_name=class_def.name,
207+
parent_class_names=[p.id for p in class_def.bases],
208+
init_method=init_method,
209+
aevaluate_method=aeval_method,
210+
)
211+
212+
return grader_info_obj
213+
214+
215+
if __name__ == "__main__":
216+
graders = get_all_grader_info()
217+
print("-------------")
218+
print(f"{len(graders)} graders")
219+
for g_i in graders:
220+
print("-------------")
221+
print(type(g_i))
222+
print(g_i)

tests/utils/test_grader_info.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env python
2+
# -*- coding: utf-8 -*-
3+
"""
4+
Unit tests for grader info utility.
5+
"""
6+
7+
import pytest
8+
9+
from openjudge.utils.grader_info import get_all_grader_info
10+
11+
12+
@pytest.mark.unit
13+
class TestGraderInfoUtil:
14+
"""Test cases for grader_info util."""
15+
16+
def test_get_graders_info(self):
17+
"""Test get_all_graders_info unti function."""
18+
all_grader_info = get_all_grader_info()
19+
20+
assert len(all_grader_info) > 0, all_grader_info
21+
for gi in all_grader_info:
22+
assert not gi["file_path"]
23+
assert len(gi.get("class_name")) > 0, gi
24+
assert isinstance(gi.get("parent_class_names"), list), gi
25+
assert len(gi.get("parent_class_names")) > 0, gi
26+
assert len(gi.get("init_method").get("signature")) > 0, gi
27+
assert len(gi.get("aevaluate_method").get("signature")) > 0, gi
28+
29+
30+
if __name__ == "__main__":
31+
pytest.main([__file__])

0 commit comments

Comments
 (0)