Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
99 commits
Select commit Hold shift + click to select a range
d355b7a
Bump major version
woojiahao Apr 7, 2025
272dc0a
Move is_local and exercise_name loading as constructor args
woojiahao Apr 7, 2025
059e5d9
Add helpers
woojiahao Apr 7, 2025
25982b8
Use singleton for answers instead
woojiahao Apr 7, 2025
bdc4676
Add answers_helper
woojiahao Apr 8, 2025
6f60416
Setup short imports using __init__.py
woojiahao Apr 8, 2025
e84d9f2
Include __all__ in __init__.py
woojiahao Apr 8, 2025
cd18d25
Propogate generic exception from AnswersParser
woojiahao Apr 8, 2025
017eca1
Bump to beta version
woojiahao Apr 8, 2025
98c1104
Include .pyi
woojiahao Apr 8, 2025
6a83cd0
Bump minor version
woojiahao Apr 8, 2025
dfa732d
Use relative paths in __init__.py
woojiahao Apr 8, 2025
3bd508b
Bump minor version
woojiahao Apr 8, 2025
841982a
Bump patch version
woojiahao Apr 8, 2025
4279593
Fix all imports
woojiahao Apr 8, 2025
7a3be91
Bump minor version
woojiahao Apr 8, 2025
2d936d8
Reorder __all__
woojiahao Apr 8, 2025
f03d47a
Bump patch version
woojiahao Apr 8, 2025
59ea81b
Remove cyclic dependency
woojiahao Apr 8, 2025
52c00f9
Bump patch version
woojiahao Apr 8, 2025
a088018
Inline imports for helpers to avoid cyclic dependency
woojiahao Apr 8, 2025
566e61d
Bump minor version
woojiahao Apr 8, 2025
9241366
Remove state from Exceptions
woojiahao Apr 8, 2025
954b6b1
Bump minor version
woojiahao Apr 8, 2025
9bc15b0
Remove unused improt
woojiahao Apr 8, 2025
fbbd2d4
Bump patch version
woojiahao Apr 8, 2025
07e124c
Add .pyi
woojiahao Apr 8, 2025
b5a1984
Bump patch version
woojiahao Apr 8, 2025
a8fb0e1
Remove .pyi
woojiahao Apr 9, 2025
c191acf
Bump patch version
woojiahao Apr 9, 2025
de801de
Add __init__ to helpers
woojiahao Apr 9, 2025
928a6eb
Bump patch version
woojiahao Apr 9, 2025
817f062
Bump patch version
woojiahao Apr 9, 2025
ef4b265
Bump patch version
woojiahao Apr 9, 2025
e230a23
Bump patch version
woojiahao Apr 9, 2025
48debe4
Rename autograder -> decorators
woojiahao Apr 9, 2025
f006632
Bump patch version
woojiahao Apr 9, 2025
a524387
Bump patch version
woojiahao Apr 9, 2025
fe8030d
Add mypy_path
woojiahao Apr 9, 2025
bab3544
Bump patch version
woojiahao Apr 9, 2025
4efa8fd
Reorder imports
woojiahao Apr 9, 2025
ffde270
Bump patch version
woojiahao Apr 9, 2025
0425a42
Add RemoteHelper
woojiahao Apr 9, 2025
f0533da
Clean up helpers
woojiahao Apr 9, 2025
7c3d352
Fix order of imports in __init__
woojiahao Apr 9, 2025
136f20f
Bump minor version
woojiahao Apr 9, 2025
fa6d2a8
Set setup function to optional
woojiahao Apr 9, 2025
76eb62d
Bump patch version
woojiahao Apr 9, 2025
08ba683
Set all answer rule errors to constant
woojiahao Apr 10, 2025
85230fb
Move diff to subpackage
woojiahao Apr 10, 2025
2f754c0
Move helper functions in Helper to individual wrapper classes
woojiahao Apr 10, 2025
c0ce45a
Bump minor version
woojiahao Apr 10, 2025
a295f2d
Add _or_none variant for BranchHelper
woojiahao Apr 10, 2025
37e5ab2
Add _or_none variant for RemoteHelper
woojiahao Apr 10, 2025
1d6bdbb
Update test case
woojiahao Apr 10, 2025
724365b
Bump patch version
woojiahao Apr 10, 2025
14397af
Fix type signature
woojiahao Apr 10, 2025
314a210
Bump patch version
woojiahao Apr 10, 2025
4a712fc
Add has_deleted and has_added line helper methods
woojiahao Apr 10, 2025
725a319
Bump patch version
woojiahao Apr 10, 2025
862192f
Add latest_commit property
woojiahao Apr 10, 2025
fe23196
Bump patch version
woojiahao Apr 10, 2025
24277f9
Use latest_commit property
woojiahao Apr 10, 2025
98a51eb
Bump patch version
woojiahao Apr 10, 2025
85d202c
Add assert_output
woojiahao Apr 10, 2025
669efb6
Add assert_output to __init__
woojiahao Apr 10, 2025
a4331a2
Bump patch version
woojiahao Apr 10, 2025
14f71ee
Fix circular imports
woojiahao Apr 10, 2025
3b2a666
Bump minor version
woojiahao Apr 10, 2025
71ad72c
Move get_file_diff as helper method
woojiahao Apr 10, 2025
bb0f9bc
Bump patch version
woojiahao Apr 10, 2025
e34e038
Add logging
woojiahao Apr 10, 2025
f657f18
Bump patch version
woojiahao Apr 10, 2025
4122a94
Add __eq__ for wrapper classes
woojiahao Apr 10, 2025
67ba91e
Bump patch version
woojiahao Apr 10, 2025
97ed73f
Create test loader to reduce duplication of setup unit test
woojiahao Apr 10, 2025
7dd2dc8
Bump patch version
woojiahao Apr 10, 2025
3e8ee84
Add GitAutograderTestLoader in __init__
woojiahao Apr 10, 2025
fedc981
Bump patch version
woojiahao Apr 10, 2025
dd6ce09
Add has_edited_line
woojiahao Apr 11, 2025
25426a6
Bump patch version
woojiahao Apr 11, 2025
b0625b2
Use difflib-parser v2
woojiahao Apr 11, 2025
543c16d
Remove src. from test
woojiahao Apr 11, 2025
2bd253e
Bump patch version
woojiahao Apr 11, 2025
7c63a35
Add FileHelper
woojiahao Apr 11, 2025
e02b648
Bump minor version
woojiahao Apr 11, 2025
2ae2d8c
Fix type hint for TextIO
woojiahao Apr 11, 2025
b71381a
Bump patch version
woojiahao Apr 11, 2025
e8192b2
Add checkout method to branch
woojiahao Apr 11, 2025
2a8ea74
Bump patch version
woojiahao Apr 11, 2025
232e227
Add helper methods and property to GitAutograderCommit
woojiahao Apr 11, 2025
bc49826
Bump patch version
woojiahao Apr 11, 2025
1a96a8a
Add helper function to get commit file change type
woojiahao Apr 11, 2025
328927b
Bump patch version
woojiahao Apr 11, 2025
eac74fc
Convert branches to property
woojiahao Apr 11, 2025
0393a4f
Bump patch version
woojiahao Apr 11, 2025
436de44
Release v3
woojiahao Apr 11, 2025
25a28c7
Update documentation
woojiahao Apr 11, 2025
c47c8e9
Add CI to run unit tests
woojiahao Apr 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
unit-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install dependencies
run: |
pip install -r requirements.txt -U --no-cache-dir
- name: Run unit tests
run: |
python -m pytest -s -vv
23 changes: 3 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,33 +12,16 @@ pip install git-autograder

`GitAutograderRepo` initializes and reads the submission repository. It contains critical information for autograding such as the start commit (denoted by `git-mastery-start-<first commit short hash>`) and user's commits.

For basic usage, you can either initialize the `GitAutograderRepo` by declaring it as a variable:
For basic usage:

```py
from git_autograder.repo import GitAutograderRepo

def grade():
repo = GitAutograderRepo()
...
```

Or by decorating the grading function with `@autograder()` where the `GitAutograderRepo` initialization is handled by the decorator:

```py
from git_autograder.autograder import autograder
from git_autograder import autograder, GitAutograderOutput, GitAutograderRepo

@autograder()
def grade(repo: GitAutograderRepo):
def grade(repo: GitAutograderRepo) -> GitAutograderOuput:
...
```

`GitAutograderDiffHelper` is a wrapper around the `git diff` between commits:

```py
from git_autograder.diff import GitAutograderDiffHelper

GitAutograderDiffHelper(commit_a, commit_b)
```

## Unit tests

Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "git-autograder"
version = "2.5.0"
version = "3.0.0"
authors = [{ name = "Jiahao, Woo", email = "[email protected]" }]
description = "Library for autograding Git repositories"
readme = "README.md"
Expand All @@ -26,3 +26,6 @@ Issues = "https://github.com/git-mastery/git-autograder/issues"
[tool.pytest.ini_options]
addopts = ["--import-mode=importlib"]
pythonpath = ["src"]

[tool.mypy]
mypy_path = "src"
27 changes: 27 additions & 0 deletions src/git_autograder/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
__all__ = [
"autograder",
"setup_autograder",
"set_env",
"assert_output",
"GitAutograderTestLoader",
"GitAutograderRepo",
"GitAutograderStatus",
"GitAutograderOutput",
"GitAutograderBranch",
"GitAutograderRemote",
"GitAutograderCommit",
]

from .status import GitAutograderStatus
from .output import GitAutograderOutput
from .repo import GitAutograderRepo
from .commit import GitAutograderCommit
from .branch import GitAutograderBranch
from .remote import GitAutograderRemote
from .decorators import autograder
from .test_utils import (
setup_autograder,
set_env,
assert_output,
GitAutograderTestLoader,
)
4 changes: 4 additions & 0 deletions src/git_autograder/answers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
__all__ = ["GitAutograderAnswersRecord", "GitAutograderAnswers"]

from .answers_record import GitAutograderAnswersRecord
from .answers import GitAutograderAnswers
80 changes: 80 additions & 0 deletions src/git_autograder/answers/answers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from dataclasses import dataclass
from typing import List, Optional

from git_autograder.answers.answers_record import GitAutograderAnswersRecord
from git_autograder.answers.rules.answer_rule import AnswerRule
from git_autograder.exception import (
GitAutograderInvalidStateException,
GitAutograderWrongAnswerException,
)


@dataclass
class GitAutograderAnswers:
MISSING_QUESTION = "Missing question {question} in answers file."

questions: List[str]
answers: List[str]

@property
def qna(self) -> List[GitAutograderAnswersRecord]:
return list(
map(
lambda a: GitAutograderAnswersRecord.from_tuple(a),
zip(self.questions, self.answers),
)
)

def __getitem__(self, key: int) -> GitAutograderAnswersRecord:
question = self.questions[key]
answer = self.answers[key]
return GitAutograderAnswersRecord.from_tuple((question, answer))

def __len__(self) -> int:
return len(self.questions)

def question_or_none(self, question: str) -> Optional[GitAutograderAnswersRecord]:
"""
Retrieves the record given a question.

:returns: GitAutograderAnswersRecord if present, else None.
:rtype: Optional[GitAutograderAnswersRecord]
:raises GitAutograderInvalidStateException: if question is not present.
"""
for i, q in enumerate(self.questions):
if question == q:
return GitAutograderAnswersRecord.from_tuple((q, self.answers[i]))
return None

def question(self, question: str) -> GitAutograderAnswersRecord:
"""
Retrieves the record given a question.

:returns: GitAutograderAnswersRecord if present.
:rtype: GitAutograderAnswersRecord
:raises GitAutograderInvalidStateException: if question is not present.
"""
record = self.question_or_none(question)
if record is None:
raise GitAutograderInvalidStateException(
self.MISSING_QUESTION.format(question=question)
)
return record

def validate_question(
self, question: str, rules: List[AnswerRule]
) -> "GitAutograderAnswers":
"""
Validates that a given GitAutograderAnswersRecord passes a set of rules.

:raises GitAutograderWrongAnswerException: when a rule is violated.
"""
q = self.question(question)

for rule in rules:
try:
rule.apply(q)
except Exception as e:
raise GitAutograderWrongAnswerException([str(e)])

return self
64 changes: 64 additions & 0 deletions src/git_autograder/answers/answers_parser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import os
from io import TextIOWrapper
from typing import List

from git_autograder.answers.answers import GitAutograderAnswers


class GitAutograderAnswersParser:
def __init__(self, path: str | os.PathLike[str]) -> None:
if not os.path.isfile(path):
raise Exception("Missing answers.txt file from repository.")

with open(path, "r") as file:
self.answers: GitAutograderAnswers = self.__parse(file)

def __parse(self, file: TextIOWrapper) -> GitAutograderAnswers:
questions: List[str] = []
answers: List[str] = []
acc_lines: List[str] = []
flag = 0 # 0 -> looking for question, 1 -> looking for answer
for line in file.readlines():
line = line.strip()
if line.lower().startswith("q:") or line.lower().startswith("a:"):
if flag == 0:
# If we were waiting for a question and found it, the previous would have been an answer
if len(acc_lines) != 0:
answers.append(self.__preserve_whitespace_join(acc_lines))
else:
# If we were waiting for an answer and found it, the previous would have been a question
if len(acc_lines) != 0:
questions.append(self.__preserve_whitespace_join(acc_lines))
acc_lines = [line[2:].strip()]
# Once a question/answer is found, we switch the flag around to wait for the next thing
flag = 1 - flag
else:
acc_lines.append(line)

if len(acc_lines) != 0:
if flag == 0:
answers.append(self.__preserve_whitespace_join(acc_lines))
else:
questions.append(self.__preserve_whitespace_join(acc_lines))

if len(questions) != len(answers):
raise Exception(
"Invalid answers format: missing question(s) or answer(s) or both"
)

return GitAutograderAnswers(questions=questions, answers=answers)

def __preserve_whitespace_join(
self, lines: List[str], delimiter: str = "\n"
) -> str:
res = []
blank_count = 0
for line in lines:
if line == "":
blank_count += 1
if blank_count > 1:
res.append(line)
else:
blank_count = 0
res.append(line)
return delimiter.join(res).strip()
31 changes: 31 additions & 0 deletions src/git_autograder/answers/answers_record.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from dataclasses import dataclass
from typing import List, Tuple


@dataclass
class GitAutograderAnswersRecord:
question: str
answer: str

def as_tuple(self) -> Tuple[str, str]:
return self.question, self.answer

@staticmethod
def from_tuple(tuple_value: Tuple[str, str]) -> "GitAutograderAnswersRecord":
return GitAutograderAnswersRecord(
question=tuple_value[0], answer=tuple_value[1]
)

def answer_as_list(self) -> List[str]:
points: List[str] = []
acc = ""
for line in self.answer.split("\n"):
if line.startswith("-"):
if acc.strip() != "":
points.append(acc.strip()[::])
acc = line[1:].strip() + "\n"
else:
acc += line + "\n"
if acc.strip() != "":
points.append(acc.strip()[::])
return points
13 changes: 13 additions & 0 deletions src/git_autograder/answers/rules/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
__all__ = [
"AnswerRule",
"HasExactValueRule",
"NotEmptyRule",
"HasExactListRule",
"ContainsListRule",
]

from .answer_rule import AnswerRule
from .has_exact_value_rule import HasExactValueRule
from .not_empty_rule import NotEmptyRule
from .has_exact_list_rule import HasExactListRule
from .contains_list_rule import ContainsListRule
8 changes: 8 additions & 0 deletions src/git_autograder/answers/rules/answer_rule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from abc import ABC, abstractmethod

from git_autograder.answers.answers_record import GitAutograderAnswersRecord


class AnswerRule(ABC):
@abstractmethod
def apply(self, answer: GitAutograderAnswersRecord) -> None: ...
30 changes: 30 additions & 0 deletions src/git_autograder/answers/rules/contains_list_rule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from typing import List

from git_autograder.answers.answers_record import GitAutograderAnswersRecord
from git_autograder.answers.rules.answer_rule import AnswerRule


class ContainsListRule(AnswerRule):
INVALID_ITEM = "Answer for {question} contains an invalid item."
ALL_INVALID = "Answer for {question} does not contain any valid items."

def __init__(
self, values: List[str], subset: bool = True, is_case_sensitive: bool = False
) -> None:
self.values = values
self.subset = subset
self.is_case_sensitive = is_case_sensitive

def apply(self, answer: GitAutograderAnswersRecord) -> None:
expected = set(
[v.lower() for v in self.values] if self.is_case_sensitive else self.values
)
given = (
[v.lower() for v in answer.answer_as_list()]
if self.is_case_sensitive
else answer.answer_as_list()
)
if self.subset and not all([v in expected for v in given]):
raise Exception(self.INVALID_ITEM.format(question=answer.question))
elif not any([v in expected for v in given]):
raise Exception(self.ALL_INVALID.format(question=answer.question))
16 changes: 16 additions & 0 deletions src/git_autograder/answers/rules/contains_value_rule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from git_autograder.answers.answers_record import GitAutograderAnswersRecord
from git_autograder.answers.rules.answer_rule import AnswerRule


class ContainsValueRule(AnswerRule):
MISSING_ANSWER = "Answer for {question} does not contain the right answer."

def __init__(self, value: str, is_case_sensitive: bool = False) -> None:
self.value = value
self.is_case_sensitive = is_case_sensitive

def apply(self, answer: GitAutograderAnswersRecord) -> None:
expected = self.value.lower() if self.is_case_sensitive else self.value
given = answer.answer.lower() if self.is_case_sensitive else answer.answer
if given not in expected:
raise Exception(self.MISSING_ANSWER.format(question=answer.question))
32 changes: 32 additions & 0 deletions src/git_autograder/answers/rules/has_exact_list_rule.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from typing import List

from git_autograder.answers.answers_record import GitAutograderAnswersRecord
from git_autograder.answers.rules.answer_rule import AnswerRule


class HasExactListRule(AnswerRule):
INCORRECT_ORDERED = "Answer for {question} does not contian all of the right answers. Ensure that they follow the order specified."
INCORRECT_UNORDERED = (
"Answer for {question} does not contain all of the right answers."
)

def __init__(
self, values: List[str], ordered: bool = False, is_case_sensitive: bool = False
) -> None:
self.values = values
self.ordered = ordered
self.is_case_sensitive = is_case_sensitive

def apply(self, answer: GitAutograderAnswersRecord) -> None:
expected = (
[v.lower() for v in self.values] if self.is_case_sensitive else self.values
)
given = (
[v.lower() for v in answer.answer_as_list()]
if self.is_case_sensitive
else answer.answer_as_list()
)
if self.ordered and expected != given:
raise Exception(self.INCORRECT_ORDERED.format(question=answer.question))
elif set(expected).intersection(set(given)) != len(expected):
raise Exception(self.INCORRECT_UNORDERED.format(question=answer.question))
Loading