Skip to content

Commit 374eb6a

Browse files
committed
feat: add grade command
1 parent 61e8fde commit 374eb6a

File tree

8 files changed

+192
-8
lines changed

8 files changed

+192
-8
lines changed

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ Note that the log for version before v0.1.1 may not be in the mentioned format.
99

1010
## [Unreleased]
1111

12+
## v0.1.2a1 - 2024-10-01
13+
14+
### Added
15+
16+
- Add `grade` command that runs all test in the homework.
17+
- Add `util.FindProblemList` that parse a README.md file for `:problem-list:` token and the list of problem names.
18+
19+
### Changed
20+
21+
- Fix some mypy error messages for function's return type and argument type.
22+
1223
## v0.1.1 - 2024-09-25
1324

1425
Please see changelog from v0.1.1a1 to v0.1.1rc3.

MAINTENANCE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,14 @@ make html
6767

6868
## Deployment flow
6969

70+
- Check that unit testing on Windows and MacOs do not fail.
71+
7072
- Run tox.
7173
- Update `grading_lib/VERSION` file.
7274
- Update `CHANGELOG.md` file.
73-
- Make a commit
74-
- Tag the commit
75-
- Push
75+
- Make a commit.
76+
- Tag the commit.
77+
- Push to GitHub.
7678
- Approve the deployment flow on GitHub Actions.
7779

7880
### Manually publish to PyPI

grading_lib/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.1
1+
0.1.2a1

grading_lib/cli/__init__.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
import copy
2+
import importlib
3+
import os
4+
import sys
5+
import unittest
16
from pathlib import Path
27

38
import click
49

5-
from ..util import get_problem_total_points, load_problems_metadata
10+
from ..common import MinimalistTestResult, MinimalistTestRunner
11+
from ..util import FindProblemList, get_problem_total_points, load_problems_metadata
612
from .dev import dev
713
from .internal import internal
814

@@ -71,5 +77,61 @@ def rebase_todo_injector_command(todo_items_file_path: str, path: str) -> None:
7177
out_f.write(todo_items_content)
7278

7379

80+
@cli.command(name="grade")
81+
@click.argument("path", default=".", type=click.Path(exists=True))
82+
def grade_command(path: str | Path) -> None:
83+
"""Grade problems at path"""
84+
# Steps:
85+
# 1. Using mistletoe, parse the README.md in the path for the problem order.
86+
# By extracting the list after the inline code token with `:problem-list:` on a heading token.
87+
# 2. Prepare the tests to execute in that order. Remove or skip the test where the students did not change the content of the target file.
88+
if isinstance(path, str):
89+
path = Path(path)
90+
problem_names = FindProblemList.from_file(path / "README.md")
91+
92+
if len(problem_names) == 0:
93+
print("No problem found.")
94+
95+
current_directory = os.getcwd()
96+
current_sys_path = copy.copy(sys.path)
97+
test_programs = []
98+
for idx, problem_name in enumerate(problem_names, start=1):
99+
print(f"{idx} - Grading {problem_name} ")
100+
101+
try:
102+
os.chdir(problem_name)
103+
104+
# Prepare sys.path for module import.
105+
sys.path.append(os.getcwd())
106+
importlib.invalidate_caches()
107+
mod = importlib.import_module("scripts.grade")
108+
109+
runner = MinimalistTestRunner(resultclass=MinimalistTestResult)
110+
test_program = unittest.main(
111+
mod, testRunner=runner, argv=[sys.argv[0]], exit=False
112+
)
113+
test_programs.append((problem_name, test_program))
114+
finally:
115+
os.chdir(current_directory)
116+
# Without copy, sys.path will be a ref to current_sys_path.
117+
sys.path = copy.copy(current_sys_path)
118+
if "scripts.grade" in sys.modules.keys():
119+
# This will be re-used by Python since we have the same module name in
120+
# every problem.
121+
del sys.modules["scripts.grade"]
122+
123+
print("\n\n")
124+
125+
# Summary.
126+
print("==== Summary ====")
127+
total_points = sum([test_program.result.total_points for _, test_program in test_programs])
128+
student_total_points = 0.0
129+
for idx, item in enumerate(test_programs, start=1):
130+
problem_name, test_program = item
131+
print(f"{idx:>3} - {problem_name:<30}{test_program.result.points:>5} / {test_program.result.total_points:>5}")
132+
student_total_points += test_program.result.points
133+
print(f"Total: {student_total_points:>5} / {total_points:>5}")
134+
135+
74136
cli.add_command(dev)
75137
cli.add_command(internal)

grading_lib/util.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
from pathlib import Path
22
from typing import Any
33

4+
import mistletoe
45
import tomli
6+
from mistletoe.block_token import (
7+
BlockToken,
8+
Heading,
9+
List,
10+
Paragraph,
11+
SetextHeading,
12+
)
13+
from mistletoe.span_token import InlineCode, SpanToken
514

615

716
def load_problems_metadata(path: Path = Path(".")) -> list[dict[str, Any]]:
@@ -23,3 +32,60 @@ def get_problem_total_points(problem: dict[str, Any]) -> float:
2332
for _, test in problem["problem"]["tests"].items():
2433
total_points += test["points"]
2534
return total_points
35+
36+
37+
class FindProblemList:
38+
"""
39+
Parse the AST of the markdown file from mistletoe for the problem list.
40+
41+
The section that has the problem list must be marked with "`::problem-list`"
42+
with
43+
"""
44+
45+
def __init__(self):
46+
self.after_problem_list_marker = False
47+
self.in_a_list = False
48+
self.problem_names = []
49+
50+
@classmethod
51+
def from_file(cls, file_path: Path) -> list[str]:
52+
with open(file_path) as f:
53+
data = f.read()
54+
document = mistletoe.Document(data)
55+
obj = cls()
56+
obj.visit_block(document)
57+
return obj.problem_names
58+
59+
def visit_text(self, token: SpanToken) -> None:
60+
if isinstance(token, InlineCode):
61+
if hasattr(token, "children") and len(token.children) != 0:
62+
if self.after_problem_list_marker and self.in_a_list:
63+
# print(token.children[0].content)
64+
self.problem_names.append(token.children[0].content)
65+
else:
66+
# print(token.children[0].content)
67+
if token.children[0].content == ":problem-list:":
68+
# print("found marker")
69+
self.after_problem_list_marker = True
70+
71+
elif hasattr(token, "children") and token.children is not None:
72+
for child in token.children:
73+
self.visit_text(child)
74+
75+
def visit_block(self, token: BlockToken) -> None:
76+
if isinstance(token, Paragraph | SetextHeading | Heading):
77+
for child in token.children:
78+
self.visit_text(child)
79+
80+
if isinstance(token, List):
81+
self.in_a_list = True
82+
for child in token.children:
83+
self.visit_block(child)
84+
self.in_a_list = False
85+
86+
if self.after_problem_list_marker:
87+
self.after_problem_list_marker = False
88+
89+
for child in token.children:
90+
if isinstance(child, BlockToken):
91+
self.visit_block(child)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dependencies = [
2121
"click",
2222
"tomli",
2323
"GitPython",
24+
"mistletoe",
2425
"typing_extensions >=4.12, <5",
2526
'importlib-metadata; python_version>="3.10"',
2627
]

tests/test_repository.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
from grading_lib.repository import Repository, RepositoryBaseTestCase
66

77

8-
def test_Repository_init_with_an_empty_folder(tmp_path) -> None:
8+
def test_Repository_init_with_an_empty_folder(tmp_path: pytest.Fixture) -> None:
99
repo = Repository(tmp_path)
1010
assert isinstance(repo.working_tree_dir, Path)
1111

1212

13-
def test_Repository_create_and_add_random_file(tmp_path) -> None:
13+
def test_Repository_create_and_add_random_file(tmp_path: pytest.Fixture) -> None:
1414
repo = Repository(tmp_path)
1515
assert isinstance(repo.working_tree_dir, Path)
1616

@@ -22,7 +22,7 @@ def test_Repository_create_and_add_random_file(tmp_path) -> None:
2222
assert len(repo.repo.index.entries) == 2
2323

2424

25-
def test_RepositoryBaseTestCase_assertHasOnlyGitCommand(tmp_path) -> None:
25+
def test_RepositoryBaseTestCase_assertHasOnlyGitCommand(tmp_path: pytest.Fixture) -> None:
2626
class Child(RepositoryBaseTestCase):
2727
pass
2828

tests/test_util.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
2+
from grading_lib.util import FindProblemList
3+
4+
5+
def test_FindProblemList(tmp_path) -> None:
6+
with open(tmp_path / "README.md", "w") as f:
7+
f.write("""
8+
# Homework 0
9+
10+
## Problem List `:problem-list:`
11+
12+
Some paragraph.
13+
14+
- `problem-a`
15+
- `problem-b`
16+
17+
## Fake Problem List
18+
19+
- `problem-c`
20+
""")
21+
22+
problem_names = FindProblemList.from_file(tmp_path / "README.md")
23+
assert len(problem_names) == 2
24+
25+
with open(tmp_path / "README2.md", "w") as f:
26+
f.write("""
27+
# Homework 0
28+
29+
## Problem List
30+
31+
Some paragraph.
32+
33+
- `problem-a`
34+
- `problem-b`
35+
36+
## Fake Problem List
37+
38+
- `problem-c`
39+
""")
40+
41+
problem_names = FindProblemList.from_file(tmp_path / "README2.md")
42+
assert len(problem_names) == 0

0 commit comments

Comments
 (0)