diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3041e64 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index 19890e9..bac0a1a 100644 --- a/README.md +++ b/README.md @@ -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-`) 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 diff --git a/pyproject.toml b/pyproject.toml index daa9468..7271e7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" @@ -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" diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index e69de29..6863efb 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -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, +) diff --git a/src/git_autograder/answers/__init__.py b/src/git_autograder/answers/__init__.py new file mode 100644 index 0000000..4f097c8 --- /dev/null +++ b/src/git_autograder/answers/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["GitAutograderAnswersRecord", "GitAutograderAnswers"] + +from .answers_record import GitAutograderAnswersRecord +from .answers import GitAutograderAnswers diff --git a/src/git_autograder/answers/answers.py b/src/git_autograder/answers/answers.py new file mode 100644 index 0000000..263cf81 --- /dev/null +++ b/src/git_autograder/answers/answers.py @@ -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 diff --git a/src/git_autograder/answers/answers_parser.py b/src/git_autograder/answers/answers_parser.py new file mode 100644 index 0000000..01de3c9 --- /dev/null +++ b/src/git_autograder/answers/answers_parser.py @@ -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() diff --git a/src/git_autograder/answers/answers_record.py b/src/git_autograder/answers/answers_record.py new file mode 100644 index 0000000..bd46527 --- /dev/null +++ b/src/git_autograder/answers/answers_record.py @@ -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 diff --git a/src/git_autograder/answers/rules/__init__.py b/src/git_autograder/answers/rules/__init__.py new file mode 100644 index 0000000..07481fc --- /dev/null +++ b/src/git_autograder/answers/rules/__init__.py @@ -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 diff --git a/src/git_autograder/answers/rules/answer_rule.py b/src/git_autograder/answers/rules/answer_rule.py new file mode 100644 index 0000000..e0cd7f9 --- /dev/null +++ b/src/git_autograder/answers/rules/answer_rule.py @@ -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: ... diff --git a/src/git_autograder/answers/rules/contains_list_rule.py b/src/git_autograder/answers/rules/contains_list_rule.py new file mode 100644 index 0000000..a9a3dcc --- /dev/null +++ b/src/git_autograder/answers/rules/contains_list_rule.py @@ -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)) diff --git a/src/git_autograder/answers/rules/contains_value_rule.py b/src/git_autograder/answers/rules/contains_value_rule.py new file mode 100644 index 0000000..6489bc3 --- /dev/null +++ b/src/git_autograder/answers/rules/contains_value_rule.py @@ -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)) diff --git a/src/git_autograder/answers/rules/has_exact_list_rule.py b/src/git_autograder/answers/rules/has_exact_list_rule.py new file mode 100644 index 0000000..ba2a1be --- /dev/null +++ b/src/git_autograder/answers/rules/has_exact_list_rule.py @@ -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)) diff --git a/src/git_autograder/answers/rules/has_exact_value_rule.py b/src/git_autograder/answers/rules/has_exact_value_rule.py new file mode 100644 index 0000000..7ca0071 --- /dev/null +++ b/src/git_autograder/answers/rules/has_exact_value_rule.py @@ -0,0 +1,17 @@ +from git_autograder.answers.answers_record import GitAutograderAnswersRecord +from git_autograder.answers.rules.answer_rule import AnswerRule + + +class HasExactValueRule(AnswerRule): + NOT_EXACT = "Answer for {question} is not right." + + def __init__(self, value: str, is_case_sensitive: bool = False) -> None: + super().__init__() + 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 != expected: + raise Exception(self.NOT_EXACT.format(answer.question)) diff --git a/src/git_autograder/answers/rules/not_empty_rule.py b/src/git_autograder/answers/rules/not_empty_rule.py new file mode 100644 index 0000000..e805ef8 --- /dev/null +++ b/src/git_autograder/answers/rules/not_empty_rule.py @@ -0,0 +1,10 @@ +from git_autograder.answers.answers_record import GitAutograderAnswersRecord +from git_autograder.answers.rules.answer_rule import AnswerRule + + +class NotEmptyRule(AnswerRule): + EMPTY = "Answer for {question} is empty." + + def apply(self, answer: GitAutograderAnswersRecord) -> None: + if answer.answer.strip() != "": + raise Exception(self.EMPTY.format(question=answer.question)) diff --git a/src/git_autograder/answers_parser.py b/src/git_autograder/answers_parser.py deleted file mode 100644 index e822c4f..0000000 --- a/src/git_autograder/answers_parser.py +++ /dev/null @@ -1,131 +0,0 @@ -import os -from io import TextIOWrapper -from dataclasses import dataclass -from typing import List, Optional, Tuple - -from git_autograder.exception import GitAutograderInvalidStateException - - -@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 - - -@dataclass -class GitAutograderAnswers: - 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 get_by_question(self, question: str) -> Optional[GitAutograderAnswersRecord]: - for i, q in enumerate(self.questions): - if question == q: - return GitAutograderAnswersRecord.from_tuple((q, self.answers[i])) - return None - - -class GitAutograderAnswersParser: - def __init__(self, path: str = "../answers.txt") -> None: - if not os.path.isfile(path): - raise GitAutograderInvalidStateException( - "Missing answers.txt file from repository.", - exercise_name=None, - is_local=None, - started_at=None, - ) - - 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 GitAutograderInvalidStateException( - "Invalid answers format: missing question(s) or answer(s) or both", - exercise_name=None, - is_local=None, - started_at=None, - ) - - 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() diff --git a/src/git_autograder/branch.py b/src/git_autograder/branch.py new file mode 100644 index 0000000..788d20c --- /dev/null +++ b/src/git_autograder/branch.py @@ -0,0 +1,111 @@ +from typing import Any, List + +from git import Head + +from git_autograder.commit import GitAutograderCommit +from git_autograder.diff import GitAutograderDiffHelper +from git_autograder.exception import GitAutograderInvalidStateException + + +class GitAutograderBranch: + MISSING_START_COMMIT = "Branch {branch} is missing the Git Mastery start commit" + MISSING_COMMITS = "Branch {branch} is missing any commits" + + def __init__(self, branch: Head) -> None: + self.branch = branch + + def __eq__(self, value: Any) -> bool: + if not isinstance(value, GitAutograderBranch): + return False + return value.branch == self.branch + + @property + def name(self) -> str: + return self.branch.name + + @property + def commits(self) -> List[GitAutograderCommit]: + """Retrieve the available commits of a given branch.""" + commits: List[GitAutograderCommit] = [] + for commit in self.branch.repo.iter_commits(self.branch): + commits.append(GitAutograderCommit(commit)) + + return commits + + @property + def start_commit(self) -> GitAutograderCommit: + """ + Find the Git Mastery start commit from the given branch. + + Raises exceptions if the branch has no commits or if the start tag is not + present. + """ + commits = self.commits + + if len(commits) == 0: + raise GitAutograderInvalidStateException( + self.MISSING_COMMITS.format(branch=self.name) + ) + + first_commit = commits[-1] + first_commit_hash = first_commit.hexsha + + start_tag_name = f"git-mastery-start-{first_commit_hash[:7]}" + + start_tag = None + for tag in self.branch.repo.tags: + if str(tag) == start_tag_name: + start_tag = tag + break + + if start_tag is None: + raise GitAutograderInvalidStateException( + self.MISSING_START_COMMIT.format(branch=self.name) + ) + + return GitAutograderCommit(start_tag.commit) + + @property + def user_commits(self) -> List[GitAutograderCommit]: + """ + Retrieves only the user commits from a given branch. + + Raises exceptions if the branch has no commits or start tag is not present. + """ + start_commit = self.start_commit + commits = self.commits + commits_asc = list(reversed(commits)) + start_commit_index = commits_asc.index(start_commit) + user_commits = commits_asc[start_commit_index + 1 :] + + return user_commits + + @property + def latest_commit(self) -> GitAutograderCommit: + return self.user_commits[-1] + + def has_non_empty_commits(self) -> bool: + """Returns if a given branch has any non-empty commits.""" + for commit in self.user_commits: + if len(commit.stats.files) > 0: + return True + return False + + def has_edited_file(self, file_path: str) -> bool: + """Returns if a given file has been edited in a given branch.""" + diff_helper = GitAutograderDiffHelper(self.start_commit, self.latest_commit) + for diff in diff_helper.iter_changes("M"): + if diff.edited_file_path == file_path: + return True + return False + + def has_added_file(self, file_path: str) -> bool: + """Returns if a given file has been added in a given branch.""" + diff_helper = GitAutograderDiffHelper(self.start_commit, self.latest_commit) + for diff in diff_helper.iter_changes("A"): + if diff.edited_file_path == file_path: + return True + return False + + def checkout(self) -> None: + self.branch.checkout() diff --git a/src/git_autograder/commit.py b/src/git_autograder/commit.py new file mode 100644 index 0000000..b4ea4f6 --- /dev/null +++ b/src/git_autograder/commit.py @@ -0,0 +1,53 @@ +from typing import Any, List, Optional, Sequence, Union + +from git import Commit, Stats + + +class GitAutograderCommit: + def __init__(self, commit: Commit) -> None: + self.commit = commit + + def __eq__(self, value: Any) -> bool: + if not isinstance(value, GitAutograderCommit): + return False + return value.commit == self.commit + + @property + def hexsha(self) -> str: + return self.commit.hexsha + + @property + def stats(self) -> Stats: + return self.commit.stats + + @property + def parents(self) -> Sequence["GitAutograderCommit"]: + return [GitAutograderCommit(parent) for parent in self.commit.parents] + + @property + def branches(self) -> List[str]: + """ + Returns the branches that contain the current commit. + """ + containing_branches = self.commit.repo.git.branch("--contains", self.hexsha) + return [line[2:] for line in containing_branches.split("\n")] + + def is_child(self, parent: Union[Commit, "GitAutograderCommit"]) -> bool: + def _is_child(child: Commit, parent: Commit) -> bool: + if child == parent: + return True + + res = False + for child_parent in child.parents: + res |= _is_child(child_parent, parent) + + return res + + return _is_child( + self.commit, parent if isinstance(parent, Commit) else parent.commit + ) + + def file_change_type(self, file_name: str) -> Optional[str]: + if file_name not in self.stats.files: + return None + return self.stats.files[file_name]["change_type"] diff --git a/src/git_autograder/autograder.py b/src/git_autograder/decorators.py similarity index 63% rename from src/git_autograder/autograder.py rename to src/git_autograder/decorators.py index fc7872d..2d908a3 100644 --- a/src/git_autograder/autograder.py +++ b/src/git_autograder/decorators.py @@ -1,4 +1,5 @@ import functools +import os from datetime import datetime from typing import Callable @@ -33,28 +34,47 @@ def inner( @functools.wraps(func) def wrapper(*args, **kwargs) -> GitAutograderOutput: output = None + is_local = os.environ.get("is_local", "false") == "true" + + exercise_name = os.environ.get("exercise_name") + if exercise_name is None: + output = GitAutograderOutput( + exercise_name=None, + started_at=None, + completed_at=None, + is_local=is_local, + comments=["Missing exercise_name in environment"], + status=GitAutograderStatus.ERROR, + ) + output.save() + return output + + repo = GitAutograderRepo(is_local=is_local, exercise_name=exercise_name) try: - repo = GitAutograderRepo() output = func(repo, *args, **kwargs) except ( GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) as e: output = GitAutograderOutput( - exercise_name=e.exercise_name, - started_at=e.started_at, + exercise_name=repo.exercise_name, + started_at=repo.started_at, completed_at=datetime.now(tz=pytz.UTC), - is_local=e.is_local, + is_local=repo.is_local, comments=[e.message] if isinstance(e.message, str) else e.message, - status=e.status, + status=( + GitAutograderStatus.ERROR + if isinstance(e, GitAutograderInvalidStateException) + else GitAutograderStatus.UNSUCCESSFUL + ), ) except Exception as e: # Unexpected exception output = GitAutograderOutput( - exercise_name=None, + exercise_name=exercise_name, started_at=None, completed_at=None, - is_local=None, + is_local=is_local, comments=[str(e)], status=GitAutograderStatus.ERROR, ) diff --git a/src/git_autograder/diff.py b/src/git_autograder/diff.py deleted file mode 100644 index f860618..0000000 --- a/src/git_autograder/diff.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import Optional, Iterator -from dataclasses import dataclass -from git import Commit, DiffIndex -from git.diff import Diff, Lit_change_type -from difflib_parser.difflib_parser import DiffParser - - -@dataclass -class GitAutograderDiff: - diff: Diff - change_type: Lit_change_type - original_file_path: Optional[str] - edited_file_path: Optional[str] - original_file: Optional[str] - edited_file: Optional[str] - diff_parser: Optional[DiffParser] - - -class GitAutograderDiffHelper: - def __init__(self, a: Commit, b: Commit) -> None: - self.diff_index: DiffIndex[Diff] = a.diff(b) - - def iter_changes(self, change_type: Lit_change_type) -> Iterator[GitAutograderDiff]: - for change in self.diff_index.iter_change_type(change_type): - original_file_rawpath = change.a_rawpath - edited_file_rawpath = change.b_rawpath - original_file_path = ( - original_file_rawpath.decode("utf-8") - if original_file_rawpath is not None - else None - ) - edited_file_path = ( - edited_file_rawpath.decode("utf-8") - if edited_file_rawpath is not None - else None - ) - original_file_blob = change.a_blob - edited_file_blob = change.b_blob - original_file = ( - original_file_blob.data_stream.read().decode("utf-8") - if original_file_blob is not None - else None - ) - edited_file = ( - edited_file_blob.data_stream.read().decode("utf-8") - if edited_file_blob is not None - else None - ) - - diff_parser = ( - DiffParser(original_file.split("\n"), edited_file.split("\n")) - if original_file is not None and edited_file is not None - else None - ) - - yield GitAutograderDiff( - change_type=change_type, - diff=change, - original_file_path=original_file_path, - edited_file_path=edited_file_path, - original_file=original_file, - edited_file=edited_file, - diff_parser=diff_parser, - ) diff --git a/src/git_autograder/diff/__init__.py b/src/git_autograder/diff/__init__.py new file mode 100644 index 0000000..942c98d --- /dev/null +++ b/src/git_autograder/diff/__init__.py @@ -0,0 +1,4 @@ +__all__ = ["GitAutograderDiff", "GitAutograderDiffHelper"] + +from .diff import GitAutograderDiff +from .diff_helper import GitAutograderDiffHelper diff --git a/src/git_autograder/diff/diff.py b/src/git_autograder/diff/diff.py new file mode 100644 index 0000000..b44d7f8 --- /dev/null +++ b/src/git_autograder/diff/diff.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass +from typing import Optional + +from difflib_parser import DiffCode, DifflibParser +from git.diff import Diff, Lit_change_type + + +@dataclass +class GitAutograderDiff: + diff: Diff + change_type: Lit_change_type + original_file_path: Optional[str] + edited_file_path: Optional[str] + original_file: Optional[str] + edited_file: Optional[str] + diff_parser: Optional[DifflibParser] + + def has_deleted_line(self) -> bool: + if self.diff_parser is None: + return False + + for diff in self.diff_parser.iter_diffs(): + if diff.code == DiffCode.LEFT_ONLY and diff.line.strip() != "": + return True + + return False + + def has_added_line(self) -> bool: + if self.diff_parser is None: + return False + + for diff in self.diff_parser.iter_diffs(): + if diff.code == DiffCode.RIGHT_ONLY and diff.line.strip() != "": + return True + + return False + + def has_edited_line(self) -> bool: + if self.diff_parser is None: + return False + + for diff in self.diff_parser.iter_diffs(): + if diff.code == DiffCode.CHANGED and diff.line.strip() != "": + return True + + return False diff --git a/src/git_autograder/diff/diff_helper.py b/src/git_autograder/diff/diff_helper.py new file mode 100644 index 0000000..b1056db --- /dev/null +++ b/src/git_autograder/diff/diff_helper.py @@ -0,0 +1,84 @@ +from typing import Iterator, List, Optional, Tuple, Union + +from difflib_parser import DifflibParser +from git import Commit, Diff, DiffIndex +from git.diff import Lit_change_type + +from git_autograder.commit import GitAutograderCommit +from git_autograder.diff.diff import GitAutograderDiff + + +class GitAutograderDiffHelper: + def __init__( + self, + a: Union[Commit, GitAutograderCommit], + b: Union[Commit, GitAutograderCommit], + ) -> None: + a_commit = self.__get_commit(a) + b_commit = self.__get_commit(b) + self.diff_index: DiffIndex[Diff] = a_commit.diff(b_commit) + + def __get_commit(self, commit: Union[Commit, GitAutograderCommit]) -> Commit: + if isinstance(commit, Commit): + return commit + return commit.commit + + @staticmethod + def get_file_diff( + a: Union[Commit, GitAutograderCommit], + b: Union[Commit, GitAutograderCommit], + file_path: str, + ) -> Optional[Tuple["GitAutograderDiff", Lit_change_type]]: + """Returns file difference between two commits across ALL change types.""" + # Based on the expectation that there can only exist one change type per file in a diff + diff_helper = GitAutograderDiffHelper(a, b) + change_types: List[Lit_change_type] = ["A", "D", "R", "M", "T"] + for change_type in change_types: + for change in diff_helper.iter_changes(change_type): + if change.diff_parser is None or change.edited_file_path != file_path: + continue + return change, change_type + return None + + def iter_changes(self, change_type: Lit_change_type) -> Iterator[GitAutograderDiff]: + for change in self.diff_index.iter_change_type(change_type): + original_file_rawpath = change.a_rawpath + edited_file_rawpath = change.b_rawpath + original_file_path = ( + original_file_rawpath.decode("utf-8") + if original_file_rawpath is not None + else None + ) + edited_file_path = ( + edited_file_rawpath.decode("utf-8") + if edited_file_rawpath is not None + else None + ) + original_file_blob = change.a_blob + edited_file_blob = change.b_blob + original_file = ( + original_file_blob.data_stream.read().decode("utf-8") + if original_file_blob is not None + else None + ) + edited_file = ( + edited_file_blob.data_stream.read().decode("utf-8") + if edited_file_blob is not None + else None + ) + + diff_parser = ( + DifflibParser(original_file.split("\n"), edited_file.split("\n")) + if original_file is not None and edited_file is not None + else None + ) + + yield GitAutograderDiff( + change_type=change_type, + diff=change, + original_file_path=original_file_path, + edited_file_path=edited_file_path, + original_file=original_file, + edited_file=edited_file, + diff_parser=diff_parser, + ) diff --git a/src/git_autograder/encoder.py b/src/git_autograder/encoder.py index 4105d8b..db3f81f 100644 --- a/src/git_autograder/encoder.py +++ b/src/git_autograder/encoder.py @@ -1,6 +1,6 @@ +from datetime import datetime from json import JSONEncoder from typing import Any -from datetime import datetime class Encoder(JSONEncoder): diff --git a/src/git_autograder/exception.py b/src/git_autograder/exception.py index ee7670b..c86afa2 100644 --- a/src/git_autograder/exception.py +++ b/src/git_autograder/exception.py @@ -1,56 +1,27 @@ -from datetime import datetime -from typing import List, Optional, Union - -from git_autograder.status import GitAutograderStatus +from typing import List, Union class GitAutograderException(Exception): def __init__( self, message: Union[str, List[str]], - exercise_name: Optional[str], - started_at: Optional[datetime], - is_local: Optional[bool], - status: GitAutograderStatus, ) -> None: super().__init__(message) self.message = message - self.exercise_name = exercise_name - self.started_at = started_at - self.is_local = is_local - self.status = status class GitAutograderInvalidStateException(GitAutograderException): def __init__( self, message: str, - exercise_name: Optional[str], - started_at: Optional[datetime], - is_local: Optional[bool], ) -> None: - super().__init__( - message, - exercise_name, - started_at, - is_local, - GitAutograderStatus.ERROR, - ) + super().__init__(message) class GitAutograderWrongAnswerException(GitAutograderException): def __init__( self, comments: List[str], - exercise_name: Optional[str], - started_at: datetime, - is_local: bool, ) -> None: - super().__init__( - comments, - exercise_name, - started_at, - is_local, - GitAutograderStatus.UNSUCCESSFUL, - ) + super().__init__(comments) diff --git a/src/git_autograder/helpers/__init__.py b/src/git_autograder/helpers/__init__.py new file mode 100644 index 0000000..938eb72 --- /dev/null +++ b/src/git_autograder/helpers/__init__.py @@ -0,0 +1,6 @@ +__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper", "FileHelper"] + +from .branch_helper import BranchHelper +from .commit_helper import CommitHelper +from .remote_helper import RemoteHelper +from .file_helper import FileHelper diff --git a/src/git_autograder/helpers/branch_helper.py b/src/git_autograder/helpers/branch_helper.py new file mode 100644 index 0000000..07da692 --- /dev/null +++ b/src/git_autograder/helpers/branch_helper.py @@ -0,0 +1,30 @@ +from typing import Optional + +from git import Repo + +from git_autograder.branch import GitAutograderBranch +from git_autograder.exception import GitAutograderInvalidStateException + + +class BranchHelper: + MISSING_BRANCH = "Branch {branch} is missing." + + def __init__(self, repo: Repo) -> None: + self.repo = repo + + def branch_or_none(self, branch_name: str) -> Optional[GitAutograderBranch]: + for head in self.repo.heads: + if head.name == branch_name: + return GitAutograderBranch(head) + return None + + def branch(self, branch_name: str) -> GitAutograderBranch: + b = self.branch_or_none(branch_name) + if b is None: + raise GitAutograderInvalidStateException( + self.MISSING_BRANCH.format(branch=branch_name) + ) + return b + + def has_branch(self, branch_name: str) -> bool: + return self.branch_or_none(branch_name) is not None diff --git a/src/git_autograder/helpers/commit_helper.py b/src/git_autograder/helpers/commit_helper.py new file mode 100644 index 0000000..77ed4ae --- /dev/null +++ b/src/git_autograder/helpers/commit_helper.py @@ -0,0 +1,15 @@ +from typing import Optional, Union + +from git import Repo +from git.types import Commit_ish + +from git_autograder.commit import GitAutograderCommit + + +class CommitHelper: + def __init__(self, repo: Repo) -> None: + self.repo = repo + + def commit(self, rev: Optional[Union[Commit_ish, str]]) -> GitAutograderCommit: + c = self.repo.commit(rev) + return GitAutograderCommit(c) diff --git a/src/git_autograder/helpers/file_helper.py b/src/git_autograder/helpers/file_helper.py new file mode 100644 index 0000000..c38e3c5 --- /dev/null +++ b/src/git_autograder/helpers/file_helper.py @@ -0,0 +1,27 @@ +import os +from contextlib import contextmanager +from typing import Iterator, Optional, Union, TextIO + +from git import Repo + + +class FileHelper: + def __init__(self, repo: Repo) -> None: + self.repo = repo + + @contextmanager + def file_or_none( + self, path: Union[str, os.PathLike[str]] + ) -> Iterator[Optional[TextIO]]: + file_path = os.path.join(self.repo.working_dir, path) + if not os.path.isfile(file_path): + yield None + else: + with open(file_path, "r") as file: + yield file + + @contextmanager + def file(self, path: Union[str, os.PathLike[str]]) -> Iterator[TextIO]: + file_path = os.path.join(self.repo.working_dir, path) + with open(file_path, "r") as file: + yield file diff --git a/src/git_autograder/helpers/remote_helper.py b/src/git_autograder/helpers/remote_helper.py new file mode 100644 index 0000000..456c055 --- /dev/null +++ b/src/git_autograder/helpers/remote_helper.py @@ -0,0 +1,30 @@ +from typing import Optional + +from git import Repo + +from git_autograder.exception import GitAutograderInvalidStateException +from git_autograder.remote import GitAutograderRemote + + +class RemoteHelper: + MISSING_REMOTE = "Remote {remote} is missing." + + def __init__(self, repo: Repo) -> None: + self.repo = repo + + def remote_or_none(self, remote_name: str) -> Optional[GitAutograderRemote]: + for remote in self.repo.remotes: + if remote.name == remote_name: + return GitAutograderRemote(remote) + return None + + def remote(self, remote_name: str) -> GitAutograderRemote: + r = self.remote_or_none(remote_name) + if r is None: + raise GitAutograderInvalidStateException( + self.MISSING_REMOTE.format(remote=remote_name) + ) + return r + + def has_remote(self, remote_name: str) -> bool: + return self.remote_or_none(remote_name) is not None diff --git a/src/git_autograder/output.py b/src/git_autograder/output.py index 3a6f901..b208546 100644 --- a/src/git_autograder/output.py +++ b/src/git_autograder/output.py @@ -4,7 +4,6 @@ from datetime import datetime from typing import ClassVar, List, Optional - from git_autograder.encoder import Encoder from git_autograder.status import GitAutograderStatus diff --git a/src/git_autograder/remote.py b/src/git_autograder/remote.py new file mode 100644 index 0000000..3e6a7a4 --- /dev/null +++ b/src/git_autograder/remote.py @@ -0,0 +1,25 @@ +from typing import Any, List + +from git import Remote + + +class GitAutograderRemote: + def __init__(self, remote: Remote) -> None: + self.remote = remote + + def __eq__(self, value: Any) -> bool: + if not isinstance(value, GitAutograderRemote): + return False + return value.remote == self.remote + + def track_branches(self, branches: List[str]) -> None: + # We start with filtering main because it should be the default branch that + # exists even on local machines. + tracked = {"main"} + for remote in self.remote.refs: + for b in branches: + if b not in tracked or f"{self.remote.name}/{b}" != remote.name: + continue + tracked.add(b) + self.remote.repo.git.checkout("-b", b, f"{self.remote.name}/{b}") + break diff --git a/src/git_autograder/repo.py b/src/git_autograder/repo.py index 92267af..5a77e9a 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -1,49 +1,73 @@ import os from datetime import datetime -from typing import List, Optional, Tuple +from pathlib import Path +from typing import List, Optional import pytz -from git import Commit, Repo -from git.diff import Lit_change_type +from git import Repo -from git_autograder.answers_parser import GitAutograderAnswersParser -from git_autograder.diff import GitAutograderDiff, GitAutograderDiffHelper +from git_autograder.answers.answers import GitAutograderAnswers +from git_autograder.answers.answers_parser import GitAutograderAnswersParser from git_autograder.exception import ( GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) -from git_autograder.status import GitAutograderStatus +from git_autograder.helpers.branch_helper import BranchHelper +from git_autograder.helpers.commit_helper import CommitHelper +from git_autograder.helpers.file_helper import FileHelper +from git_autograder.helpers.remote_helper import RemoteHelper from git_autograder.output import GitAutograderOutput +from git_autograder.status import GitAutograderStatus class GitAutograderRepo: def __init__( self, + is_local: bool, + exercise_name: str, repo_path: Optional[str | os.PathLike] = None, ) -> None: - self.__started_at = self.__now() - self.is_local: bool = os.environ.get("is_local", "false") == "true" - self.__exercise_name = os.environ.get("repository_name") - self.__repo_path = repo_path + # TODO: We should not be starting the grading at the point of initializing, but + # we're keeping this because of the exception system + self.started_at = self.__now() + self.is_local: bool = is_local + self.exercise_name = exercise_name + self.repo_path = ( + repo_path + if repo_path is not None + else Path.cwd().parent / "main" + if not is_local + else Path.cwd().parent / "exercises" / exercise_name + ) - if self.__exercise_name is None: - raise GitAutograderInvalidStateException( - "Missing repository name", - self.__exercise_name, - self.__started_at, - self.is_local, - ) + self.repo: Repo = Repo(self.repo_path) - # TODO Set this up to be more dynamic - self.repo: Repo = ( - Repo(self.__repo_path) - if self.__repo_path is not None - else ( - Repo("../main") - if not self.is_local - else Repo(f"../exercises/{self.__exercise_name}") - ) - ) + self.branches: BranchHelper = BranchHelper(self.repo) + self.commits: CommitHelper = CommitHelper(self.repo) + self.remotes: RemoteHelper = RemoteHelper(self.repo) + self.files: FileHelper = FileHelper(self.repo) + self.__answers_parser: Optional[GitAutograderAnswersParser] = None + self.__answers: Optional[GitAutograderAnswers] = None + + @property + def answers(self) -> GitAutograderAnswers: + """Parses a QnA file (answers.txt). Verifies that the file exists.""" + # We need to use singleton patterns here since we want to avoid repeatedly parsing + # These are all optional to start since the grader might not require answers + if self.__answers_parser is None: + answers_file_path = Path(self.repo_path) / "answers.txt" + # Use singleton for answers parser + try: + self.__answers_parser = GitAutograderAnswersParser(answers_file_path) + except Exception as e: + raise GitAutograderInvalidStateException( + str(e), + ) + + if self.__answers is None: + self.__answers = self.__answers_parser.answers + + return self.__answers @staticmethod def __now() -> datetime: @@ -58,8 +82,8 @@ def to_output( If there is no status provided, the status will be inferred from the comments. """ return GitAutograderOutput( - exercise_name=self.__exercise_name, - started_at=self.__started_at, + exercise_name=self.exercise_name, + started_at=self.started_at, completed_at=self.__now(), is_local=self.is_local, comments=comments, @@ -73,156 +97,4 @@ def to_output( ) def wrong_answer(self, comments: List[str]) -> GitAutograderWrongAnswerException: - return GitAutograderWrongAnswerException( - comments, self.__exercise_name, self.__started_at, self.is_local - ) - - def track_remote_branches(self, remotes: List[str], strict: bool = False) -> None: - if self.is_local: - return - - tracked = {"main"} - for remote in self.repo.remote("origin").refs: - for r in remotes: - if r not in tracked or f"origin/{r}" != remote.name: - continue - tracked.add(r) - self.repo.git.checkout("-b", r, f"origin/{r}") - break - - missed_remotes = list(set(remotes).difference(tracked)) - if len(missed_remotes) > 0 and strict: - raise GitAutograderInvalidStateException( - f"Missing branches {', '.join(missed_remotes)} in submission", - self.__exercise_name, - self.__started_at, - self.is_local, - ) - - def answers(self) -> GitAutograderAnswersParser: - """Parses a QnA file (answers.txt). Verifies that the file exists.""" - return ( - GitAutograderAnswersParser(f"{self.__repo_path}/answers.txt") - if self.__repo_path is not None - else GitAutograderAnswersParser("../main/answers.txt") - if not self.is_local - else GitAutograderAnswersParser( - f"../exercises/{self.__exercise_name}/answers.txt" - ) - ) - - def commits(self, branch: str = "main") -> List[Commit]: - """Retrieve the available commits of a given branch.""" - commits = [] - for commit in self.repo.iter_commits(branch): - commits.append(commit) - - return commits - - def start_commit(self, branch: str = "main") -> Commit: - """ - Find the Git Mastery start commit from the given branch. - - Raises exceptions if the branch has no commits or if the start tag is not - present. - """ - first_commit = None - commits = self.commits(branch) - for commit in self.repo.iter_commits(branch): - first_commit = commit - commits.append(commit) - - if len(commits) == 0: - raise GitAutograderInvalidStateException( - f"Branch {branch} is missing any commits", - self.__exercise_name, - self.__started_at, - self.is_local, - ) - - assert first_commit is not None - - first_commit_hash = first_commit.hexsha - start_tag_name = f"git-mastery-start-{first_commit_hash[:7]}" - - start_tag = None - for tag in self.repo.tags: - if str(tag) == start_tag_name: - start_tag = tag - break - - if start_tag is None: - raise GitAutograderInvalidStateException( - f"Branch {branch} is missing the Git Mastery start commit", - self.__exercise_name, - self.__started_at, - self.is_local, - ) - - return start_tag.commit - - def user_commits(self, branch: str = "main") -> List[Commit]: - """ - Retrieves only the user commits from a given branch. - - Raises exceptions if the branch has no commits or start tag is not present. - """ - start_commit = self.start_commit(branch) - commits = self.commits(branch) - commits_asc = list(reversed(commits)) - start_commit_index = commits_asc.index(start_commit) - user_commits = commits_asc[start_commit_index + 1 :] - - return user_commits - - def has_non_empty_commits(self, branch: str = "main") -> bool: - """Returns if a given branch has any non-empty commits.""" - for commit in self.user_commits(branch): - if len(commit.stats.files) > 0: - return True - return False - - def has_edited_file(self, file_path: str, branch: str = "main") -> bool: - """Returns if a given file has been edited in a given branch.""" - latest_commit = self.user_commits(branch)[-1] - diff_helper = GitAutograderDiffHelper(self.start_commit(branch), latest_commit) - for diff in diff_helper.iter_changes("M"): - if diff.edited_file_path == file_path: - return True - return False - - def has_added_file(self, file_path: str, branch: str = "main") -> bool: - """Returns if a given file has been added in a given branch.""" - latest_commit = self.user_commits(branch)[-1] - diff_helper = GitAutograderDiffHelper(self.start_commit(branch), latest_commit) - for diff in diff_helper.iter_changes("A"): - if diff.edited_file_path == file_path: - return True - return False - - def get_file_diff( - self, a: Commit, b: Commit, file_path: str - ) -> Optional[Tuple[GitAutograderDiff, Lit_change_type]]: - """Returns file difference between two commits across ALL change types.""" - # Based on the expectation that there can only exist one change type per file in a diff - diff_helper = GitAutograderDiffHelper(a, b) - change_types: List[Lit_change_type] = ["A", "D", "R", "M", "T"] - for change_type in change_types: - for change in diff_helper.iter_changes(change_type): - if change.diff_parser is None or change.edited_file_path != file_path: - continue - return change, change_type - return None - - def has_branch(self, branch: str) -> bool: - return branch in self.repo.heads - - def is_child_commit(self, child: Commit, commit: Commit) -> bool: - if child == commit: - return True - - res = False - for parent in child.parents: - res |= self.is_child_commit(parent, commit) - - return res + return GitAutograderWrongAnswerException(comments) diff --git a/src/git_autograder/test_utils.py b/src/git_autograder/test_utils.py index 2e37220..9aa3393 100644 --- a/src/git_autograder/test_utils.py +++ b/src/git_autograder/test_utils.py @@ -1,7 +1,7 @@ import os from contextlib import contextmanager from datetime import datetime -from typing import Callable, Iterator, Optional +from typing import Callable, Iterator, List, Optional from unittest import mock import pytz @@ -32,43 +32,124 @@ def set_env(**kwargs) -> mock._patch_dict: return mock.patch.dict(os.environ, kwargs, clear=True) +class GitAutograderTestLoader: + def __init__( + self, + exercise_name: str, + grade_func: Callable[[GitAutograderRepo], GitAutograderOutput], + ) -> None: + self.exercise_name = exercise_name + self.grade_func = grade_func + + @contextmanager + def load( + self, + spec_path: str, + step_id: str, + setup: Optional[Callable[[Repo], None]] = None, + ) -> Iterator[GitAutograderOutput]: + repo_initializer = initialize_repo(spec_path) + attach_start_tag(repo_initializer, step_id) + with repo_initializer.initialize() as r: + if setup is not None: + setup(r) + output: Optional[GitAutograderOutput] = None + started_at = datetime.now(tz=pytz.UTC) + try: + autograder = GitAutograderRepo( + is_local=True, + exercise_name=self.exercise_name, + repo_path=r.working_dir, + ) + output = self.grade_func(autograder) + except ( + GitAutograderInvalidStateException, + GitAutograderWrongAnswerException, + ) as e: + output = GitAutograderOutput( + exercise_name=self.exercise_name, + started_at=started_at, + completed_at=datetime.now(tz=pytz.UTC), + is_local=True, + comments=[e.message] if isinstance(e.message, str) else e.message, + status=( + GitAutograderStatus.ERROR + if isinstance(e, GitAutograderInvalidStateException) + else GitAutograderStatus.UNSUCCESSFUL + ), + ) + except Exception as e: + # Unexpected exception + output = GitAutograderOutput( + exercise_name=self.exercise_name, + started_at=None, + completed_at=None, + is_local=True, + comments=[str(e)], + status=GitAutograderStatus.ERROR, + ) + + assert output is not None + yield output + + @contextmanager def setup_autograder( + exercise_name: str, spec_path: str, step_id: str, grade_func: Callable[[GitAutograderRepo], GitAutograderOutput], - setup: Callable[[Repo], None], + setup: Optional[Callable[[Repo], None]] = None, ) -> Iterator[GitAutograderOutput]: repo_initializer = initialize_repo(spec_path) attach_start_tag(repo_initializer, step_id) with repo_initializer.initialize() as r: - setup(r) + if setup is not None: + setup(r) output: Optional[GitAutograderOutput] = None + started_at = datetime.now(tz=pytz.UTC) try: - autograder = GitAutograderRepo(repo_path=r.working_dir) + autograder = GitAutograderRepo( + is_local=True, exercise_name=exercise_name, repo_path=r.working_dir + ) output = grade_func(autograder) except ( GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) as e: output = GitAutograderOutput( - exercise_name=e.exercise_name, - started_at=e.started_at, + exercise_name=exercise_name, + started_at=started_at, completed_at=datetime.now(tz=pytz.UTC), - is_local=e.is_local, + is_local=True, comments=[e.message] if isinstance(e.message, str) else e.message, - status=e.status, + status=( + GitAutograderStatus.ERROR + if isinstance(e, GitAutograderInvalidStateException) + else GitAutograderStatus.UNSUCCESSFUL + ), ) except Exception as e: # Unexpected exception output = GitAutograderOutput( - exercise_name=None, + exercise_name=exercise_name, started_at=None, completed_at=None, - is_local=None, + is_local=True, comments=[str(e)], status=GitAutograderStatus.ERROR, ) assert output is not None yield output + + +def assert_output( + output: GitAutograderOutput, + expected_status: GitAutograderStatus, + expected_comments: List[str] = [], +) -> None: + assert output.status == expected_status + assert len(set(output.comments or []) & set(expected_comments)) == len( + expected_comments + ) diff --git a/tests/test_answers_parser.py b/tests/test_answers_parser.py index 9e079db..fb88317 100644 --- a/tests/test_answers_parser.py +++ b/tests/test_answers_parser.py @@ -2,8 +2,8 @@ import pytest -from src.git_autograder.answers_parser import GitAutograderAnswersParser -from src.git_autograder.exception import ( +from git_autograder.answers.answers_parser import GitAutograderAnswersParser +from git_autograder.exception import ( GitAutograderException, GitAutograderInvalidStateException, ) @@ -78,7 +78,7 @@ def test_answer_as_list(): def test_get_by_question(): parser = GitAutograderAnswersParser(path=get_path("single_line_answers")) - hello_answer = parser.answers.get_by_question("Hello") - assert parser.answers.get_by_question("Bye") is None + hello_answer = parser.answers.question_or_none("Hello") + assert parser.answers.question_or_none("Bye") is None assert hello_answer is not None assert hello_answer.answer == "World"