From d355b7ad1618394d9b123ad806c3ca7810e93252 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Mon, 7 Apr 2025 17:20:54 +0800 Subject: [PATCH 01/99] Bump major version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index daa9468..44f0358 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" From 272dc0ae63358fa6da47551066c42a6c85f2d3ff Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Mon, 7 Apr 2025 19:58:50 +0800 Subject: [PATCH 02/99] Move is_local and exercise_name loading as constructor args They should not care where the variables come from --- src/git_autograder/autograder.py | 10 ++++++++- src/git_autograder/repo.py | 36 +++++++++++++------------------- src/git_autograder/test_utils.py | 5 ++++- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/git_autograder/autograder.py b/src/git_autograder/autograder.py index fc7872d..714c004 100644 --- a/src/git_autograder/autograder.py +++ b/src/git_autograder/autograder.py @@ -1,5 +1,6 @@ import functools from datetime import datetime +import os from typing import Callable import pytz @@ -34,7 +35,14 @@ def inner( def wrapper(*args, **kwargs) -> GitAutograderOutput: output = None try: - repo = GitAutograderRepo() + is_local = os.environ.get("is_local", "false") == "true" + exercise_name = os.environ.get("exercise_name") + if exercise_name is None: + raise GitAutograderInvalidStateException( + "Missing exercise_name in environment", None, None, is_local + ) + repo = GitAutograderRepo(is_local=is_local, exercise_name=exercise_name) + repo.start() output = func(repo, *args, **kwargs) except ( GitAutograderInvalidStateException, diff --git a/src/git_autograder/repo.py b/src/git_autograder/repo.py index 92267af..7a0a45b 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -5,6 +5,7 @@ import pytz from git import Commit, Repo from git.diff import Lit_change_type +from pathlib import Path from git_autograder.answers_parser import GitAutograderAnswersParser from git_autograder.diff import GitAutograderDiff, GitAutograderDiffHelper @@ -19,31 +20,24 @@ 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 + 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}") - ) - ) + def start(self) -> None: + self.__started_at = self.__now() @staticmethod def __now() -> datetime: diff --git a/src/git_autograder/test_utils.py b/src/git_autograder/test_utils.py index 2e37220..e8be783 100644 --- a/src/git_autograder/test_utils.py +++ b/src/git_autograder/test_utils.py @@ -34,6 +34,7 @@ def set_env(**kwargs) -> mock._patch_dict: @contextmanager def setup_autograder( + exercise_name: str, spec_path: str, step_id: str, grade_func: Callable[[GitAutograderRepo], GitAutograderOutput], @@ -45,7 +46,9 @@ def setup_autograder( setup(r) output: Optional[GitAutograderOutput] = None try: - autograder = GitAutograderRepo(repo_path=r.working_dir) + autograder = GitAutograderRepo( + is_local=False, exercise_name=exercise_name, repo_path=r.working_dir + ) output = grade_func(autograder) except ( GitAutograderInvalidStateException, From 059e5d9d8feb5e3db09e8019e4c6d0c6fc7953e8 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Mon, 7 Apr 2025 21:46:40 +0800 Subject: [PATCH 03/99] Add helpers Use helpers instead of embedding all helper methods within repo itself --- src/git_autograder/autograder.py | 1 - src/git_autograder/helpers/branch_helper.py | 33 ++++ src/git_autograder/helpers/commit_helper.py | 80 ++++++++++ src/git_autograder/helpers/grader_helper.py | 64 ++++++++ src/git_autograder/repo.py | 168 +++----------------- src/git_autograder/repo_context.py | 14 ++ 6 files changed, 211 insertions(+), 149 deletions(-) create mode 100644 src/git_autograder/helpers/branch_helper.py create mode 100644 src/git_autograder/helpers/commit_helper.py create mode 100644 src/git_autograder/helpers/grader_helper.py create mode 100644 src/git_autograder/repo_context.py diff --git a/src/git_autograder/autograder.py b/src/git_autograder/autograder.py index 714c004..b8501f7 100644 --- a/src/git_autograder/autograder.py +++ b/src/git_autograder/autograder.py @@ -42,7 +42,6 @@ def wrapper(*args, **kwargs) -> GitAutograderOutput: "Missing exercise_name in environment", None, None, is_local ) repo = GitAutograderRepo(is_local=is_local, exercise_name=exercise_name) - repo.start() output = func(repo, *args, **kwargs) except ( GitAutograderInvalidStateException, diff --git a/src/git_autograder/helpers/branch_helper.py b/src/git_autograder/helpers/branch_helper.py new file mode 100644 index 0000000..182b9a4 --- /dev/null +++ b/src/git_autograder/helpers/branch_helper.py @@ -0,0 +1,33 @@ +from typing import List +from git_autograder.exception import GitAutograderInvalidStateException +from git_autograder.repo_context import GitAutograderRepoContext + + +class BranchHelper: + def __init__(self, ctx: GitAutograderRepoContext) -> None: + self.ctx = ctx + + def track_remote_branches(self, remotes: List[str], strict: bool = False) -> None: + if self.ctx.is_local: + return + + tracked = {"main"} + for remote in self.ctx.repo.repo.remote("origin").refs: + for r in remotes: + if r not in tracked or f"origin/{r}" != remote.name: + continue + tracked.add(r) + self.ctx.repo.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.ctx.exercise_name, + self.ctx.started_at, + self.ctx.is_local, + ) + + def has_branch(self, branch: str) -> bool: + return branch in self.ctx.repo.repo.heads diff --git a/src/git_autograder/helpers/commit_helper.py b/src/git_autograder/helpers/commit_helper.py new file mode 100644 index 0000000..ccf6e7a --- /dev/null +++ b/src/git_autograder/helpers/commit_helper.py @@ -0,0 +1,80 @@ +from typing import List + +from git import Commit +from git_autograder.exception import GitAutograderInvalidStateException +from git_autograder.repo_context import GitAutograderRepoContext + + +class CommitHelper: + def __init__(self, ctx: GitAutograderRepoContext) -> None: + self.ctx = ctx + + def is_child_commit(self, child: Commit, parent: Commit) -> bool: + if child == parent: + return True + + res = False + for parent in child.parents: + res |= self.is_child_commit(parent, parent) + + return res + + def commits(self, branch: str = "main") -> List[Commit]: + """Retrieve the available commits of a given branch.""" + commits = [] + for commit in self.ctx.repo.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. + """ + commits = self.commits(branch) + + if len(commits) == 0: + raise GitAutograderInvalidStateException( + f"Branch {branch} is missing any commits", + self.ctx.exercise_name, + self.ctx.started_at, + self.ctx.is_local, + ) + + 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.ctx.repo.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.ctx.exercise_name, + self.ctx.started_at, + self.ctx.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 diff --git a/src/git_autograder/helpers/grader_helper.py b/src/git_autograder/helpers/grader_helper.py new file mode 100644 index 0000000..9d3b7b7 --- /dev/null +++ b/src/git_autograder/helpers/grader_helper.py @@ -0,0 +1,64 @@ +from typing import List, Optional, Tuple + +from git import Commit +from git.diff import Lit_change_type + +from git_autograder.diff import GitAutograderDiff, GitAutograderDiffHelper +from git_autograder.helpers.branch_helper import BranchHelper +from git_autograder.helpers.commit_helper import CommitHelper +from git_autograder.repo_context import GitAutograderRepoContext + + +class GraderHelper: + def __init__( + self, + ctx: GitAutograderRepoContext, + branch_helper: BranchHelper, + commit_helper: CommitHelper, + ) -> None: + self.ctx = ctx + self.branch_helper = branch_helper + self.commit_helper = commit_helper + + def has_non_empty_commits(self, branch: str = "main") -> bool: + """Returns if a given branch has any non-empty commits.""" + for commit in self.commit_helper.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.commit_helper.user_commits(branch)[-1] + diff_helper = GitAutograderDiffHelper( + self.commit_helper.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.commit_helper.user_commits(branch)[-1] + diff_helper = GitAutograderDiffHelper( + self.commit_helper.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 diff --git a/src/git_autograder/repo.py b/src/git_autograder/repo.py index 7a0a45b..5f044b3 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -1,20 +1,21 @@ 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 pathlib import Path +from git import Repo from git_autograder.answers_parser import GitAutograderAnswersParser -from git_autograder.diff import GitAutograderDiff, GitAutograderDiffHelper 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.grader_helper import GraderHelper from git_autograder.output import GitAutograderOutput +from git_autograder.repo_context import GitAutograderRepoContext +from git_autograder.status import GitAutograderStatus class GitAutograderRepo: @@ -24,6 +25,8 @@ def __init__( exercise_name: str, repo_path: Optional[str | os.PathLike] = None, ) -> None: + # 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 = ( @@ -35,9 +38,16 @@ def __init__( ) self.repo: Repo = Repo(self.__repo_path) - - def start(self) -> None: - self.__started_at = self.__now() + self.ctx = GitAutograderRepoContext( + repo=self, + started_at=self.__started_at, + is_local=self.is_local, + repo_path=self.__repo_path, + exercise_name=self.__exercise_name, + ) + self.branches: BranchHelper = BranchHelper(self.ctx) + self.commits: CommitHelper = CommitHelper(self.ctx) + self.grader: GraderHelper = GraderHelper(self.ctx, self.branches, self.commits) @staticmethod def __now() -> datetime: @@ -71,28 +81,6 @@ def wrong_answer(self, comments: List[str]) -> 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 ( @@ -104,119 +92,3 @@ def answers(self) -> 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 diff --git a/src/git_autograder/repo_context.py b/src/git_autograder/repo_context.py new file mode 100644 index 0000000..761e82f --- /dev/null +++ b/src/git_autograder/repo_context.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from datetime import datetime +import os +from typing import Optional +from git_autograder.repo import GitAutograderRepo + + +@dataclass +class GitAutograderRepoContext: + repo: GitAutograderRepo + is_local: bool + exercise_name: str + started_at: datetime + repo_path: Optional[str | os.PathLike] = None From 25982b8fee4933136a39fc0e3589bf4dd2c43ef6 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 00:36:25 +0800 Subject: [PATCH 04/99] Use singleton for answers instead --- src/git_autograder/answers/answers.py | 33 ++++++++++ .../{ => answers}/answers_parser.py | 64 +------------------ src/git_autograder/answers/answers_record.py | 31 +++++++++ src/git_autograder/repo.py | 26 ++++---- tests/test_answers_parser.py | 2 +- 5 files changed, 81 insertions(+), 75 deletions(-) create mode 100644 src/git_autograder/answers/answers.py rename src/git_autograder/{ => answers}/answers_parser.py (59%) create mode 100644 src/git_autograder/answers/answers_record.py diff --git a/src/git_autograder/answers/answers.py b/src/git_autograder/answers/answers.py new file mode 100644 index 0000000..c91c977 --- /dev/null +++ b/src/git_autograder/answers/answers.py @@ -0,0 +1,33 @@ +from dataclasses import dataclass +from typing import List, Optional + +from git_autograder.answers.answers_record import GitAutograderAnswersRecord + + +@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 diff --git a/src/git_autograder/answers_parser.py b/src/git_autograder/answers/answers_parser.py similarity index 59% rename from src/git_autograder/answers_parser.py rename to src/git_autograder/answers/answers_parser.py index e822c4f..c3efe4a 100644 --- a/src/git_autograder/answers_parser.py +++ b/src/git_autograder/answers/answers_parser.py @@ -1,71 +1,13 @@ import os from io import TextIOWrapper -from dataclasses import dataclass -from typing import List, Optional, Tuple +from typing import List 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 +from git_autograder.answers.answers import GitAutograderAnswers class GitAutograderAnswersParser: - def __init__(self, path: str = "../answers.txt") -> None: + def __init__(self, path: str | os.PathLike[str]) -> None: if not os.path.isfile(path): raise GitAutograderInvalidStateException( "Missing answers.txt file from repository.", 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/repo.py b/src/git_autograder/repo.py index 5f044b3..7ddae7c 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -6,7 +6,8 @@ import pytz from git import Repo -from git_autograder.answers_parser import GitAutograderAnswersParser +from git_autograder.answers.answers import GitAutograderAnswers +from git_autograder.answers.answers_parser import GitAutograderAnswersParser from git_autograder.exception import ( GitAutograderWrongAnswerException, ) @@ -48,6 +49,17 @@ def __init__( self.branches: BranchHelper = BranchHelper(self.ctx) self.commits: CommitHelper = CommitHelper(self.ctx) self.grader: GraderHelper = GraderHelper(self.ctx, self.branches, self.commits) + self.__answers_parser: Optional[GitAutograderAnswersParser] = None + + @property + def answers(self) -> GitAutograderAnswers: + """Parses a QnA file (answers.txt). Verifies that the file exists.""" + if self.__answers_parser is None: + answers_file_path = Path(self.__repo_path) / "answers.txt" + # Use singleton for answers parser + self.__answers_parser = GitAutograderAnswersParser(answers_file_path) + + return self.__answers_parser.answers @staticmethod def __now() -> datetime: @@ -80,15 +92,3 @@ def wrong_answer(self, comments: List[str]) -> GitAutograderWrongAnswerException return GitAutograderWrongAnswerException( comments, 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" - ) - ) diff --git a/tests/test_answers_parser.py b/tests/test_answers_parser.py index 9e079db..df7fb48 100644 --- a/tests/test_answers_parser.py +++ b/tests/test_answers_parser.py @@ -2,7 +2,7 @@ import pytest -from src.git_autograder.answers_parser import GitAutograderAnswersParser +from src.git_autograder.answers.answers_parser import GitAutograderAnswersParser from src.git_autograder.exception import ( GitAutograderException, GitAutograderInvalidStateException, From bdc4676a376d2566d014bc1db72d28baea4d0d56 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 12:34:00 +0800 Subject: [PATCH 05/99] Add answers_helper --- src/git_autograder/answers/rules/__init__.py | 12 ++++++ .../answers/rules/answer_rule.py | 7 ++++ .../answers/rules/contains_list_rule.py | 28 +++++++++++++ .../answers/rules/contains_value_rule.py | 16 +++++++ .../answers/rules/has_exact_list_rule.py | 30 +++++++++++++ .../answers/rules/has_exact_value_rule.py | 15 +++++++ .../answers/rules/not_empty_rule.py | 8 ++++ src/git_autograder/helpers/answers_helper.py | 42 +++++++++++++++++++ src/git_autograder/repo.py | 15 ++++--- 9 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 src/git_autograder/answers/rules/__init__.py create mode 100644 src/git_autograder/answers/rules/answer_rule.py create mode 100644 src/git_autograder/answers/rules/contains_list_rule.py create mode 100644 src/git_autograder/answers/rules/contains_value_rule.py create mode 100644 src/git_autograder/answers/rules/has_exact_list_rule.py create mode 100644 src/git_autograder/answers/rules/has_exact_value_rule.py create mode 100644 src/git_autograder/answers/rules/not_empty_rule.py create mode 100644 src/git_autograder/helpers/answers_helper.py diff --git a/src/git_autograder/answers/rules/__init__.py b/src/git_autograder/answers/rules/__init__.py new file mode 100644 index 0000000..13d0904 --- /dev/null +++ b/src/git_autograder/answers/rules/__init__.py @@ -0,0 +1,12 @@ +from git_autograder.answers.rules.answer_rule import AnswerRule +from git_autograder.answers.rules.has_exact_value_rule import HasExactValueRule +from git_autograder.answers.rules.not_empty_rule import NotEmptyRule +from git_autograder.answers.rules.has_exact_list_rule import HasExactListRule +from git_autograder.answers.rules.contains_list_rule import ContainsListRule + +__all__ = [ + "HasExactValueRule", + "NotEmptyRule", + "HasExactListRule", + "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..7ffbbb3 --- /dev/null +++ b/src/git_autograder/answers/rules/answer_rule.py @@ -0,0 +1,7 @@ +from git_autograder.answers.answers_record import GitAutograderAnswersRecord +from abc import ABC, abstractmethod + + +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..13c638e --- /dev/null +++ b/src/git_autograder/answers/rules/contains_list_rule.py @@ -0,0 +1,28 @@ +from typing import List +from git_autograder.answers.rules import AnswerRule +from git_autograder.answers import GitAutograderAnswersRecord + + +class ContainsListRule(AnswerRule): + 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(f"Answer for {answer.question} contains an invalid item.") + elif not any([v in expected for v in given]): + raise Exception( + f"Answer for {answer.question} does not contain any valid items." + ) 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..ba1184c --- /dev/null +++ b/src/git_autograder/answers/rules/contains_value_rule.py @@ -0,0 +1,16 @@ +from git_autograder.answers.rules import AnswerRule +from git_autograder.answers import GitAutograderAnswersRecord + + +class ContainsValueRule(AnswerRule): + 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( + f"Answer for {answer.question} does not contain the right answer." + ) 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..dfed8a3 --- /dev/null +++ b/src/git_autograder/answers/rules/has_exact_list_rule.py @@ -0,0 +1,30 @@ +from typing import List +from git_autograder.answers.rules import AnswerRule +from git_autograder.answers import GitAutograderAnswersRecord + + +class HasExactListRule(AnswerRule): + 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( + f"Answer for {answer.question} does not contain all of the right answers. Ensure that they follow the order specified." + ) + elif set(expected).intersection(set(given)) != len(expected): + raise Exception( + f"Answer for {answer.question} does not contain all of the right answers." + ) 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..3754263 --- /dev/null +++ b/src/git_autograder/answers/rules/has_exact_value_rule.py @@ -0,0 +1,15 @@ +from git_autograder.answers.rules import AnswerRule +from git_autograder.answers import GitAutograderAnswersRecord + + +class HasExactValueRule(AnswerRule): + 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(f"Answer for {answer.question} is not right.") 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..6486866 --- /dev/null +++ b/src/git_autograder/answers/rules/not_empty_rule.py @@ -0,0 +1,8 @@ +from git_autograder.answers.answers_record import GitAutograderAnswersRecord +from git_autograder.answers.rules import AnswerRule + + +class NotEmptyRule(AnswerRule): + def apply(self, answer: GitAutograderAnswersRecord) -> None: + if answer.answer.strip() != "": + raise Exception(f"Answer for {answer.question} is empty.") diff --git a/src/git_autograder/helpers/answers_helper.py b/src/git_autograder/helpers/answers_helper.py new file mode 100644 index 0000000..bc5b102 --- /dev/null +++ b/src/git_autograder/helpers/answers_helper.py @@ -0,0 +1,42 @@ +from typing import List + +from git_autograder.answers import GitAutograderAnswers +from git_autograder.answers.rules import AnswerRule +from git_autograder.exception import ( + GitAutograderInvalidStateException, + GitAutograderWrongAnswerException, +) +from git_autograder.repo_context import GitAutograderRepoContext + + +class AnswersHelper: + def __init__( + self, ctx: GitAutograderRepoContext, answers: GitAutograderAnswers + ) -> None: + self.ctx = ctx + self.answers = answers + + def validate_question( + self, question: str, rules: List[AnswerRule] + ) -> "AnswersHelper": + answer = self.answers.get_by_question(question) + if answer is None: + raise GitAutograderInvalidStateException( + f"Missing question {question} in answers file.", + exercise_name=self.ctx.exercise_name, + is_local=self.ctx.is_local, + started_at=self.ctx.started_at, + ) + + for rule in rules: + try: + rule.apply(answer) + except Exception as e: + raise GitAutograderWrongAnswerException( + [str(e)], + exercise_name=self.ctx.exercise_name, + is_local=self.ctx.is_local, + started_at=self.ctx.started_at, + ) + + return self diff --git a/src/git_autograder/repo.py b/src/git_autograder/repo.py index 7ddae7c..cc048ca 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -6,17 +6,16 @@ import pytz from git import Repo -from git_autograder.answers.answers import GitAutograderAnswers +from git_autograder import GitAutograderOutput, GitAutograderStatus from git_autograder.answers.answers_parser import GitAutograderAnswersParser from git_autograder.exception import ( GitAutograderWrongAnswerException, ) +from git_autograder.helpers.answers_helper import AnswersHelper from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.grader_helper import GraderHelper -from git_autograder.output import GitAutograderOutput from git_autograder.repo_context import GitAutograderRepoContext -from git_autograder.status import GitAutograderStatus class GitAutograderRepo: @@ -50,16 +49,22 @@ def __init__( self.commits: CommitHelper = CommitHelper(self.ctx) self.grader: GraderHelper = GraderHelper(self.ctx, self.branches, self.commits) self.__answers_parser: Optional[GitAutograderAnswersParser] = None + self.__answers: Optional[AnswersHelper] = None @property - def answers(self) -> GitAutograderAnswers: + def answers(self) -> AnswersHelper: """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 self.__answers_parser = GitAutograderAnswersParser(answers_file_path) - return self.__answers_parser.answers + if self.__answers is None: + self.__answers = AnswersHelper(self.ctx, self.__answers_parser.answers) + + return self.__answers @staticmethod def __now() -> datetime: From 6f604165bf65c46ab4c7bfb60182cf198910e904 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 12:34:10 +0800 Subject: [PATCH 06/99] Setup short imports using __init__.py --- src/git_autograder/__init__.py | 5 +++++ src/git_autograder/answers/__init__.py | 2 ++ src/git_autograder/autograder.py | 6 ++---- src/git_autograder/exception.py | 2 +- src/git_autograder/output.py | 3 +-- src/git_autograder/repo_context.py | 2 +- src/git_autograder/test_utils.py | 4 +--- 7 files changed, 13 insertions(+), 11 deletions(-) create mode 100644 src/git_autograder/answers/__init__.py diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index e69de29..bee3b02 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -0,0 +1,5 @@ +from git_autograder.autograder import autograder +from git_autograder.test_utils import setup_autograder, set_env +from git_autograder.repo import GitAutograderRepo +from git_autograder.status import GitAutograderStatus +from git_autograder.output import GitAutograderOutput diff --git a/src/git_autograder/answers/__init__.py b/src/git_autograder/answers/__init__.py new file mode 100644 index 0000000..013eef0 --- /dev/null +++ b/src/git_autograder/answers/__init__.py @@ -0,0 +1,2 @@ +from git_autograder.answers.answers_record import GitAutograderAnswersRecord +from git_autograder.answers.answers import GitAutograderAnswers diff --git a/src/git_autograder/autograder.py b/src/git_autograder/autograder.py index b8501f7..211370e 100644 --- a/src/git_autograder/autograder.py +++ b/src/git_autograder/autograder.py @@ -1,17 +1,15 @@ import functools -from datetime import datetime import os +from datetime import datetime from typing import Callable import pytz +from git_autograder import GitAutograderOutput, GitAutograderRepo, GitAutograderStatus from git_autograder.exception import ( GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) -from git_autograder.output import GitAutograderOutput -from git_autograder.repo import GitAutograderRepo -from git_autograder.status import GitAutograderStatus def autograder() -> Callable[ diff --git a/src/git_autograder/exception.py b/src/git_autograder/exception.py index ee7670b..e86956b 100644 --- a/src/git_autograder/exception.py +++ b/src/git_autograder/exception.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import List, Optional, Union -from git_autograder.status import GitAutograderStatus +from git_autograder import GitAutograderStatus class GitAutograderException(Exception): diff --git a/src/git_autograder/output.py b/src/git_autograder/output.py index 3a6f901..3181fb4 100644 --- a/src/git_autograder/output.py +++ b/src/git_autograder/output.py @@ -4,9 +4,8 @@ from datetime import datetime from typing import ClassVar, List, Optional - +from git_autograder import GitAutograderStatus from git_autograder.encoder import Encoder -from git_autograder.status import GitAutograderStatus @dataclass diff --git a/src/git_autograder/repo_context.py b/src/git_autograder/repo_context.py index 761e82f..42ccbed 100644 --- a/src/git_autograder/repo_context.py +++ b/src/git_autograder/repo_context.py @@ -2,7 +2,7 @@ from datetime import datetime import os from typing import Optional -from git_autograder.repo import GitAutograderRepo +from git_autograder import GitAutograderRepo @dataclass diff --git a/src/git_autograder/test_utils.py b/src/git_autograder/test_utils.py index e8be783..44fb596 100644 --- a/src/git_autograder/test_utils.py +++ b/src/git_autograder/test_utils.py @@ -8,13 +8,11 @@ from git import Repo from repo_smith.initialize_repo import RepoInitializer, initialize_repo +from git_autograder import GitAutograderOutput, GitAutograderRepo, GitAutograderStatus from git_autograder.exception import ( GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) -from git_autograder.output import GitAutograderOutput -from git_autograder.repo import GitAutograderRepo -from git_autograder.status import GitAutograderStatus def attach_start_tag(repo_initializer: RepoInitializer, step_id: str) -> None: From e84d9f2d86c742aa677dccd44679312b6547a82b Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 12:35:15 +0800 Subject: [PATCH 07/99] Include __all__ in __init__.py --- src/git_autograder/__init__.py | 9 +++++++++ src/git_autograder/answers/__init__.py | 2 ++ src/git_autograder/answers/rules/__init__.py | 1 + 3 files changed, 12 insertions(+) diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index bee3b02..7dc2736 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -3,3 +3,12 @@ from git_autograder.repo import GitAutograderRepo from git_autograder.status import GitAutograderStatus from git_autograder.output import GitAutograderOutput + +__all__ = [ + "autograder", + "setup_autograder", + "set_env", + "GitAutograderRepo", + "GitAutograderStatus", + "GitAutograderOutput", +] diff --git a/src/git_autograder/answers/__init__.py b/src/git_autograder/answers/__init__.py index 013eef0..8dc9cdc 100644 --- a/src/git_autograder/answers/__init__.py +++ b/src/git_autograder/answers/__init__.py @@ -1,2 +1,4 @@ from git_autograder.answers.answers_record import GitAutograderAnswersRecord from git_autograder.answers.answers import GitAutograderAnswers + +__all__ = ["GitAutograderAnswersRecord", "GitAutograderAnswers"] diff --git a/src/git_autograder/answers/rules/__init__.py b/src/git_autograder/answers/rules/__init__.py index 13d0904..59be173 100644 --- a/src/git_autograder/answers/rules/__init__.py +++ b/src/git_autograder/answers/rules/__init__.py @@ -5,6 +5,7 @@ from git_autograder.answers.rules.contains_list_rule import ContainsListRule __all__ = [ + "AnswerRule", "HasExactValueRule", "NotEmptyRule", "HasExactListRule", From cd18d25c987f9c69d92877d0daddaf08f1eacb2d Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 12:40:49 +0800 Subject: [PATCH 08/99] Propogate generic exception from AnswersParser Allows us to return exercise name when parsing answers file --- src/git_autograder/answers/answers_parser.py | 14 +++----------- src/git_autograder/repo.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/git_autograder/answers/answers_parser.py b/src/git_autograder/answers/answers_parser.py index c3efe4a..b2c20a7 100644 --- a/src/git_autograder/answers/answers_parser.py +++ b/src/git_autograder/answers/answers_parser.py @@ -9,12 +9,7 @@ class GitAutograderAnswersParser: def __init__(self, path: str | os.PathLike[str]) -> None: if not os.path.isfile(path): - raise GitAutograderInvalidStateException( - "Missing answers.txt file from repository.", - exercise_name=None, - is_local=None, - started_at=None, - ) + raise Exception("Missing answers.txt file from repository.") with open(path, "r") as file: self.answers: GitAutograderAnswers = self.__parse(file) @@ -48,11 +43,8 @@ def __parse(self, file: TextIOWrapper) -> GitAutograderAnswers: 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, + raise Exception( + "Invalid answers format: missing question(s) or answer(s) or both" ) return GitAutograderAnswers(questions=questions, answers=answers) diff --git a/src/git_autograder/repo.py b/src/git_autograder/repo.py index cc048ca..bf90e7d 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -9,6 +9,7 @@ from git_autograder import GitAutograderOutput, GitAutograderStatus from git_autograder.answers.answers_parser import GitAutograderAnswersParser from git_autograder.exception import ( + GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) from git_autograder.helpers.answers_helper import AnswersHelper @@ -59,7 +60,15 @@ def answers(self) -> AnswersHelper: if self.__answers_parser is None: answers_file_path = Path(self.__repo_path) / "answers.txt" # Use singleton for answers parser - self.__answers_parser = GitAutograderAnswersParser(answers_file_path) + try: + self.__answers_parser = GitAutograderAnswersParser(answers_file_path) + except Exception as e: + raise GitAutograderInvalidStateException( + str(e), + exercise_name=self.__exercise_name, + is_local=self.is_local, + started_at=self.__started_at, + ) if self.__answers is None: self.__answers = AnswersHelper(self.ctx, self.__answers_parser.answers) From 017eca1435f72d4bec228bc0890b8d9f4c9311e0 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 12:42:59 +0800 Subject: [PATCH 09/99] Bump to beta version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 44f0358..6bafa62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.0.0" +version = "3.0.0b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 98c1104539aaeea9bf7bfc51f74c03c71351d021 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 14:12:22 +0800 Subject: [PATCH 10/99] Include .pyi --- src/git_autograder/__init__.pyi | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/git_autograder/__init__.pyi diff --git a/src/git_autograder/__init__.pyi b/src/git_autograder/__init__.pyi new file mode 100644 index 0000000..7dc2736 --- /dev/null +++ b/src/git_autograder/__init__.pyi @@ -0,0 +1,14 @@ +from git_autograder.autograder import autograder +from git_autograder.test_utils import setup_autograder, set_env +from git_autograder.repo import GitAutograderRepo +from git_autograder.status import GitAutograderStatus +from git_autograder.output import GitAutograderOutput + +__all__ = [ + "autograder", + "setup_autograder", + "set_env", + "GitAutograderRepo", + "GitAutograderStatus", + "GitAutograderOutput", +] From 6a83cd083017b7674d1fccda24a0051babdc1da5 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 14:12:27 +0800 Subject: [PATCH 11/99] Bump minor version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6bafa62..801130e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.0.0b1" +version = "3.1.0b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From dfa732dbd802899fe03a3dcf81abb52bc1b6bd38 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 14:15:26 +0800 Subject: [PATCH 12/99] Use relative paths in __init__.py --- src/git_autograder/__init__.py | 10 +++++----- src/git_autograder/__init__.pyi | 14 -------------- src/git_autograder/answers/__init__.py | 4 ++-- src/git_autograder/answers/rules/__init__.py | 10 +++++----- 4 files changed, 12 insertions(+), 26 deletions(-) delete mode 100644 src/git_autograder/__init__.pyi diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index 7dc2736..4175ce1 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -1,8 +1,8 @@ -from git_autograder.autograder import autograder -from git_autograder.test_utils import setup_autograder, set_env -from git_autograder.repo import GitAutograderRepo -from git_autograder.status import GitAutograderStatus -from git_autograder.output import GitAutograderOutput +from .autograder import autograder +from .test_utils import setup_autograder, set_env +from .repo import GitAutograderRepo +from .status import GitAutograderStatus +from .output import GitAutograderOutput __all__ = [ "autograder", diff --git a/src/git_autograder/__init__.pyi b/src/git_autograder/__init__.pyi deleted file mode 100644 index 7dc2736..0000000 --- a/src/git_autograder/__init__.pyi +++ /dev/null @@ -1,14 +0,0 @@ -from git_autograder.autograder import autograder -from git_autograder.test_utils import setup_autograder, set_env -from git_autograder.repo import GitAutograderRepo -from git_autograder.status import GitAutograderStatus -from git_autograder.output import GitAutograderOutput - -__all__ = [ - "autograder", - "setup_autograder", - "set_env", - "GitAutograderRepo", - "GitAutograderStatus", - "GitAutograderOutput", -] diff --git a/src/git_autograder/answers/__init__.py b/src/git_autograder/answers/__init__.py index 8dc9cdc..4415904 100644 --- a/src/git_autograder/answers/__init__.py +++ b/src/git_autograder/answers/__init__.py @@ -1,4 +1,4 @@ -from git_autograder.answers.answers_record import GitAutograderAnswersRecord -from git_autograder.answers.answers import GitAutograderAnswers +from .answers_record import GitAutograderAnswersRecord +from .answers import GitAutograderAnswers __all__ = ["GitAutograderAnswersRecord", "GitAutograderAnswers"] diff --git a/src/git_autograder/answers/rules/__init__.py b/src/git_autograder/answers/rules/__init__.py index 59be173..708393b 100644 --- a/src/git_autograder/answers/rules/__init__.py +++ b/src/git_autograder/answers/rules/__init__.py @@ -1,8 +1,8 @@ -from git_autograder.answers.rules.answer_rule import AnswerRule -from git_autograder.answers.rules.has_exact_value_rule import HasExactValueRule -from git_autograder.answers.rules.not_empty_rule import NotEmptyRule -from git_autograder.answers.rules.has_exact_list_rule import HasExactListRule -from git_autograder.answers.rules.contains_list_rule import 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 __all__ = [ "AnswerRule", From 3bd508bbf609af7c942b9b656b5284a780c99502 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 14:15:37 +0800 Subject: [PATCH 13/99] Bump minor version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 801130e..603dc5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.1.0b1" +version = "3.2.0b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 841982a8d6c540fecc410ba0c7c33f0ce7f302b0 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 14:35:12 +0800 Subject: [PATCH 14/99] Bump patch version --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 603dc5e..ccb57fa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.2.0b1" +version = "3.2.1b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" @@ -26,3 +26,9 @@ Issues = "https://github.com/git-mastery/git-autograder/issues" [tool.pytest.ini_options] addopts = ["--import-mode=importlib"] pythonpath = ["src"] + +[tool.hatch.build] +include = [ + "src/git_autograder/py.typed", + "src/git_autograder/**/*.py", +] From 42795933296f85b206b37c48e50beb0b391028c6 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 15:01:08 +0800 Subject: [PATCH 15/99] Fix all imports --- src/git_autograder/answers/answers_parser.py | 1 - src/git_autograder/answers/rules/answer_rule.py | 3 ++- src/git_autograder/answers/rules/contains_list_rule.py | 5 +++-- src/git_autograder/answers/rules/contains_value_rule.py | 4 ++-- src/git_autograder/answers/rules/has_exact_list_rule.py | 5 +++-- src/git_autograder/answers/rules/has_exact_value_rule.py | 4 ++-- src/git_autograder/answers/rules/not_empty_rule.py | 2 +- src/git_autograder/autograder.py | 4 +++- src/git_autograder/diff.py | 5 +++-- src/git_autograder/encoder.py | 2 +- src/git_autograder/exception.py | 2 +- src/git_autograder/helpers/branch_helper.py | 1 + src/git_autograder/helpers/commit_helper.py | 1 + src/git_autograder/output.py | 2 +- src/git_autograder/repo.py | 3 ++- src/git_autograder/repo_context.py | 5 +++-- src/git_autograder/test_utils.py | 4 +++- 17 files changed, 32 insertions(+), 21 deletions(-) diff --git a/src/git_autograder/answers/answers_parser.py b/src/git_autograder/answers/answers_parser.py index b2c20a7..01de3c9 100644 --- a/src/git_autograder/answers/answers_parser.py +++ b/src/git_autograder/answers/answers_parser.py @@ -2,7 +2,6 @@ from io import TextIOWrapper from typing import List -from git_autograder.exception import GitAutograderInvalidStateException from git_autograder.answers.answers import GitAutograderAnswers diff --git a/src/git_autograder/answers/rules/answer_rule.py b/src/git_autograder/answers/rules/answer_rule.py index 7ffbbb3..e0cd7f9 100644 --- a/src/git_autograder/answers/rules/answer_rule.py +++ b/src/git_autograder/answers/rules/answer_rule.py @@ -1,6 +1,7 @@ -from git_autograder.answers.answers_record import GitAutograderAnswersRecord from abc import ABC, abstractmethod +from git_autograder.answers.answers_record import GitAutograderAnswersRecord + class AnswerRule(ABC): @abstractmethod diff --git a/src/git_autograder/answers/rules/contains_list_rule.py b/src/git_autograder/answers/rules/contains_list_rule.py index 13c638e..3f8469a 100644 --- a/src/git_autograder/answers/rules/contains_list_rule.py +++ b/src/git_autograder/answers/rules/contains_list_rule.py @@ -1,6 +1,7 @@ from typing import List -from git_autograder.answers.rules import AnswerRule -from git_autograder.answers import GitAutograderAnswersRecord + +from git_autograder.answers.answers_record import GitAutograderAnswersRecord +from git_autograder.answers.rules.answer_rule import AnswerRule class ContainsListRule(AnswerRule): diff --git a/src/git_autograder/answers/rules/contains_value_rule.py b/src/git_autograder/answers/rules/contains_value_rule.py index ba1184c..50deae3 100644 --- a/src/git_autograder/answers/rules/contains_value_rule.py +++ b/src/git_autograder/answers/rules/contains_value_rule.py @@ -1,5 +1,5 @@ -from git_autograder.answers.rules import AnswerRule -from git_autograder.answers import GitAutograderAnswersRecord +from git_autograder.answers.answers_record import GitAutograderAnswersRecord +from git_autograder.answers.rules.answer_rule import AnswerRule class ContainsValueRule(AnswerRule): diff --git a/src/git_autograder/answers/rules/has_exact_list_rule.py b/src/git_autograder/answers/rules/has_exact_list_rule.py index dfed8a3..1000f35 100644 --- a/src/git_autograder/answers/rules/has_exact_list_rule.py +++ b/src/git_autograder/answers/rules/has_exact_list_rule.py @@ -1,6 +1,7 @@ from typing import List -from git_autograder.answers.rules import AnswerRule -from git_autograder.answers import GitAutograderAnswersRecord + +from git_autograder.answers.answers_record import GitAutograderAnswersRecord +from git_autograder.answers.rules.answer_rule import AnswerRule class HasExactListRule(AnswerRule): diff --git a/src/git_autograder/answers/rules/has_exact_value_rule.py b/src/git_autograder/answers/rules/has_exact_value_rule.py index 3754263..f779078 100644 --- a/src/git_autograder/answers/rules/has_exact_value_rule.py +++ b/src/git_autograder/answers/rules/has_exact_value_rule.py @@ -1,5 +1,5 @@ -from git_autograder.answers.rules import AnswerRule -from git_autograder.answers import GitAutograderAnswersRecord +from git_autograder.answers.answers_record import GitAutograderAnswersRecord +from git_autograder.answers.rules.answer_rule import AnswerRule class HasExactValueRule(AnswerRule): diff --git a/src/git_autograder/answers/rules/not_empty_rule.py b/src/git_autograder/answers/rules/not_empty_rule.py index 6486866..4508d8b 100644 --- a/src/git_autograder/answers/rules/not_empty_rule.py +++ b/src/git_autograder/answers/rules/not_empty_rule.py @@ -1,5 +1,5 @@ from git_autograder.answers.answers_record import GitAutograderAnswersRecord -from git_autograder.answers.rules import AnswerRule +from git_autograder.answers.rules.answer_rule import AnswerRule class NotEmptyRule(AnswerRule): diff --git a/src/git_autograder/autograder.py b/src/git_autograder/autograder.py index 211370e..4c31d1b 100644 --- a/src/git_autograder/autograder.py +++ b/src/git_autograder/autograder.py @@ -5,11 +5,13 @@ import pytz -from git_autograder import GitAutograderOutput, GitAutograderRepo, GitAutograderStatus from git_autograder.exception import ( GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) +from git_autograder.output import GitAutograderOutput +from git_autograder.repo import GitAutograderRepo +from git_autograder.status import GitAutograderStatus def autograder() -> Callable[ diff --git a/src/git_autograder/diff.py b/src/git_autograder/diff.py index f860618..06c63bb 100644 --- a/src/git_autograder/diff.py +++ b/src/git_autograder/diff.py @@ -1,8 +1,9 @@ -from typing import Optional, Iterator from dataclasses import dataclass +from typing import Iterator, Optional + +from difflib_parser.difflib_parser import DiffParser from git import Commit, DiffIndex from git.diff import Diff, Lit_change_type -from difflib_parser.difflib_parser import DiffParser @dataclass 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 e86956b..ee7670b 100644 --- a/src/git_autograder/exception.py +++ b/src/git_autograder/exception.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import List, Optional, Union -from git_autograder import GitAutograderStatus +from git_autograder.status import GitAutograderStatus class GitAutograderException(Exception): diff --git a/src/git_autograder/helpers/branch_helper.py b/src/git_autograder/helpers/branch_helper.py index 182b9a4..2b618a1 100644 --- a/src/git_autograder/helpers/branch_helper.py +++ b/src/git_autograder/helpers/branch_helper.py @@ -1,4 +1,5 @@ from typing import List + from git_autograder.exception import GitAutograderInvalidStateException from git_autograder.repo_context import GitAutograderRepoContext diff --git a/src/git_autograder/helpers/commit_helper.py b/src/git_autograder/helpers/commit_helper.py index ccf6e7a..19a4d93 100644 --- a/src/git_autograder/helpers/commit_helper.py +++ b/src/git_autograder/helpers/commit_helper.py @@ -1,6 +1,7 @@ from typing import List from git import Commit + from git_autograder.exception import GitAutograderInvalidStateException from git_autograder.repo_context import GitAutograderRepoContext diff --git a/src/git_autograder/output.py b/src/git_autograder/output.py index 3181fb4..b208546 100644 --- a/src/git_autograder/output.py +++ b/src/git_autograder/output.py @@ -4,8 +4,8 @@ from datetime import datetime from typing import ClassVar, List, Optional -from git_autograder import GitAutograderStatus from git_autograder.encoder import Encoder +from git_autograder.status import GitAutograderStatus @dataclass diff --git a/src/git_autograder/repo.py b/src/git_autograder/repo.py index bf90e7d..3e5144a 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -6,7 +6,6 @@ import pytz from git import Repo -from git_autograder import GitAutograderOutput, GitAutograderStatus from git_autograder.answers.answers_parser import GitAutograderAnswersParser from git_autograder.exception import ( GitAutograderInvalidStateException, @@ -16,7 +15,9 @@ from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.grader_helper import GraderHelper +from git_autograder.output import GitAutograderOutput from git_autograder.repo_context import GitAutograderRepoContext +from git_autograder.status import GitAutograderStatus class GitAutograderRepo: diff --git a/src/git_autograder/repo_context.py b/src/git_autograder/repo_context.py index 42ccbed..3e658a5 100644 --- a/src/git_autograder/repo_context.py +++ b/src/git_autograder/repo_context.py @@ -1,8 +1,9 @@ +import os from dataclasses import dataclass from datetime import datetime -import os from typing import Optional -from git_autograder import GitAutograderRepo + +from git_autograder.repo import GitAutograderRepo @dataclass diff --git a/src/git_autograder/test_utils.py b/src/git_autograder/test_utils.py index 44fb596..e8be783 100644 --- a/src/git_autograder/test_utils.py +++ b/src/git_autograder/test_utils.py @@ -8,11 +8,13 @@ from git import Repo from repo_smith.initialize_repo import RepoInitializer, initialize_repo -from git_autograder import GitAutograderOutput, GitAutograderRepo, GitAutograderStatus from git_autograder.exception import ( GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) +from git_autograder.output import GitAutograderOutput +from git_autograder.repo import GitAutograderRepo +from git_autograder.status import GitAutograderStatus def attach_start_tag(repo_initializer: RepoInitializer, step_id: str) -> None: From 7a3be918a9ce6a0b58e9827288b340f7ea341feb Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 15:01:12 +0800 Subject: [PATCH 16/99] Bump minor version --- pyproject.toml | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ccb57fa..bcfa997 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.2.1b1" +version = "3.3.0b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" @@ -26,9 +26,3 @@ Issues = "https://github.com/git-mastery/git-autograder/issues" [tool.pytest.ini_options] addopts = ["--import-mode=importlib"] pythonpath = ["src"] - -[tool.hatch.build] -include = [ - "src/git_autograder/py.typed", - "src/git_autograder/**/*.py", -] From 2d936d8570a154bb184079111e8a8d3691ab9a2a Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 15:12:49 +0800 Subject: [PATCH 17/99] Reorder __all__ --- src/git_autograder/__init__.py | 12 ++++++------ src/git_autograder/answers/__init__.py | 4 ++-- src/git_autograder/answers/rules/__init__.py | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index 4175ce1..07252f9 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -1,9 +1,3 @@ -from .autograder import autograder -from .test_utils import setup_autograder, set_env -from .repo import GitAutograderRepo -from .status import GitAutograderStatus -from .output import GitAutograderOutput - __all__ = [ "autograder", "setup_autograder", @@ -12,3 +6,9 @@ "GitAutograderStatus", "GitAutograderOutput", ] + +from .autograder import autograder +from .test_utils import setup_autograder, set_env +from .repo import GitAutograderRepo +from .status import GitAutograderStatus +from .output import GitAutograderOutput diff --git a/src/git_autograder/answers/__init__.py b/src/git_autograder/answers/__init__.py index 4415904..4f097c8 100644 --- a/src/git_autograder/answers/__init__.py +++ b/src/git_autograder/answers/__init__.py @@ -1,4 +1,4 @@ +__all__ = ["GitAutograderAnswersRecord", "GitAutograderAnswers"] + from .answers_record import GitAutograderAnswersRecord from .answers import GitAutograderAnswers - -__all__ = ["GitAutograderAnswersRecord", "GitAutograderAnswers"] diff --git a/src/git_autograder/answers/rules/__init__.py b/src/git_autograder/answers/rules/__init__.py index 708393b..07481fc 100644 --- a/src/git_autograder/answers/rules/__init__.py +++ b/src/git_autograder/answers/rules/__init__.py @@ -1,9 +1,3 @@ -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 - __all__ = [ "AnswerRule", "HasExactValueRule", @@ -11,3 +5,9 @@ "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 From f03d47ab2717a2f8f3f1272cfc2641b150a9d78d Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 15:12:55 +0800 Subject: [PATCH 18/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index bcfa997..9f24312 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.3.0b1" +version = "3.3.1b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 59ea81b7aa3f71d7b36b1a6dcf4578862599f5e8 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 21:47:53 +0800 Subject: [PATCH 19/99] Remove cyclic dependency --- src/git_autograder/helpers/answers_helper.py | 4 ++-- src/git_autograder/helpers/branch_helper.py | 4 ++-- src/git_autograder/helpers/commit_helper.py | 4 ++-- src/git_autograder/helpers/grader_helper.py | 4 ++-- src/git_autograder/repo.py | 12 ++++++++++-- src/git_autograder/repo_context.py | 15 --------------- 6 files changed, 18 insertions(+), 25 deletions(-) delete mode 100644 src/git_autograder/repo_context.py diff --git a/src/git_autograder/helpers/answers_helper.py b/src/git_autograder/helpers/answers_helper.py index bc5b102..f1ac450 100644 --- a/src/git_autograder/helpers/answers_helper.py +++ b/src/git_autograder/helpers/answers_helper.py @@ -6,12 +6,12 @@ GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) -from git_autograder.repo_context import GitAutograderRepoContext +from git_autograder.repo import GitAutograderRepo class AnswersHelper: def __init__( - self, ctx: GitAutograderRepoContext, answers: GitAutograderAnswers + self, ctx: GitAutograderRepo.Context, answers: GitAutograderAnswers ) -> None: self.ctx = ctx self.answers = answers diff --git a/src/git_autograder/helpers/branch_helper.py b/src/git_autograder/helpers/branch_helper.py index 2b618a1..3ee6e37 100644 --- a/src/git_autograder/helpers/branch_helper.py +++ b/src/git_autograder/helpers/branch_helper.py @@ -1,11 +1,11 @@ from typing import List from git_autograder.exception import GitAutograderInvalidStateException -from git_autograder.repo_context import GitAutograderRepoContext +from git_autograder.repo import GitAutograderRepo class BranchHelper: - def __init__(self, ctx: GitAutograderRepoContext) -> None: + def __init__(self, ctx: GitAutograderRepo.Context) -> None: self.ctx = ctx def track_remote_branches(self, remotes: List[str], strict: bool = False) -> None: diff --git a/src/git_autograder/helpers/commit_helper.py b/src/git_autograder/helpers/commit_helper.py index 19a4d93..8e7d681 100644 --- a/src/git_autograder/helpers/commit_helper.py +++ b/src/git_autograder/helpers/commit_helper.py @@ -3,11 +3,11 @@ from git import Commit from git_autograder.exception import GitAutograderInvalidStateException -from git_autograder.repo_context import GitAutograderRepoContext +from git_autograder.repo import GitAutograderRepo class CommitHelper: - def __init__(self, ctx: GitAutograderRepoContext) -> None: + def __init__(self, ctx: GitAutograderRepo.Context) -> None: self.ctx = ctx def is_child_commit(self, child: Commit, parent: Commit) -> bool: diff --git a/src/git_autograder/helpers/grader_helper.py b/src/git_autograder/helpers/grader_helper.py index 9d3b7b7..24299df 100644 --- a/src/git_autograder/helpers/grader_helper.py +++ b/src/git_autograder/helpers/grader_helper.py @@ -6,13 +6,13 @@ from git_autograder.diff import GitAutograderDiff, GitAutograderDiffHelper from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper -from git_autograder.repo_context import GitAutograderRepoContext +from git_autograder.repo import GitAutograderRepo class GraderHelper: def __init__( self, - ctx: GitAutograderRepoContext, + ctx: GitAutograderRepo.Context, branch_helper: BranchHelper, commit_helper: CommitHelper, ) -> None: diff --git a/src/git_autograder/repo.py b/src/git_autograder/repo.py index 3e5144a..7b03369 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass import os from datetime import datetime from pathlib import Path @@ -16,11 +17,18 @@ from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.grader_helper import GraderHelper from git_autograder.output import GitAutograderOutput -from git_autograder.repo_context import GitAutograderRepoContext from git_autograder.status import GitAutograderStatus class GitAutograderRepo: + @dataclass + class Context: + repo: "GitAutograderRepo" + is_local: bool + exercise_name: str + started_at: datetime + repo_path: Optional[str | os.PathLike] = None + def __init__( self, is_local: bool, @@ -40,7 +48,7 @@ def __init__( ) self.repo: Repo = Repo(self.__repo_path) - self.ctx = GitAutograderRepoContext( + self.ctx = self.Context( repo=self, started_at=self.__started_at, is_local=self.is_local, diff --git a/src/git_autograder/repo_context.py b/src/git_autograder/repo_context.py deleted file mode 100644 index 3e658a5..0000000 --- a/src/git_autograder/repo_context.py +++ /dev/null @@ -1,15 +0,0 @@ -import os -from dataclasses import dataclass -from datetime import datetime -from typing import Optional - -from git_autograder.repo import GitAutograderRepo - - -@dataclass -class GitAutograderRepoContext: - repo: GitAutograderRepo - is_local: bool - exercise_name: str - started_at: datetime - repo_path: Optional[str | os.PathLike] = None From 52c00f9da93f6e14d6734bd9f6a1baeae2b37037 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 21:48:03 +0800 Subject: [PATCH 20/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9f24312..391d357 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.3.1b1" +version = "3.4.0b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From a0880184bb2cc5447565ebb444a1af91cad3dd41 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 22:09:03 +0800 Subject: [PATCH 21/99] Inline imports for helpers to avoid cyclic dependency --- src/git_autograder/repo.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/git_autograder/repo.py b/src/git_autograder/repo.py index 7b03369..6b3a9b8 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -1,5 +1,5 @@ -from dataclasses import dataclass import os +from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import List, Optional @@ -12,10 +12,6 @@ GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) -from git_autograder.helpers.answers_helper import AnswersHelper -from git_autograder.helpers.branch_helper import BranchHelper -from git_autograder.helpers.commit_helper import CommitHelper -from git_autograder.helpers.grader_helper import GraderHelper from git_autograder.output import GitAutograderOutput from git_autograder.status import GitAutograderStatus @@ -55,14 +51,25 @@ def __init__( repo_path=self.__repo_path, exercise_name=self.__exercise_name, ) + + # Doing this to break the cyclic dependency + from git_autograder.helpers.answers_helper import AnswersHelper + from git_autograder.helpers.branch_helper import BranchHelper + from git_autograder.helpers.commit_helper import CommitHelper + from git_autograder.helpers.grader_helper import GraderHelper + self.branches: BranchHelper = BranchHelper(self.ctx) self.commits: CommitHelper = CommitHelper(self.ctx) self.grader: GraderHelper = GraderHelper(self.ctx, self.branches, self.commits) self.__answers_parser: Optional[GitAutograderAnswersParser] = None self.__answers: Optional[AnswersHelper] = None + from git_autograder.helpers.answers_helper import AnswersHelper + @property def answers(self) -> AnswersHelper: + from git_autograder.helpers.answers_helper import AnswersHelper + """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 From 566e61db39f0d17febef9cc4e03b73fc0419502c Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 22:09:13 +0800 Subject: [PATCH 22/99] Bump minor version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 391d357..db965b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.4.0b1" +version = "3.5.0b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 9241366e8b4bccd1581786e0e4b009ba5eb369de Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 23:18:27 +0800 Subject: [PATCH 23/99] Remove state from Exceptions Move the state to be injected by the decorator or in the test_util --- src/git_autograder/autograder.py | 39 ++++++++----- src/git_autograder/exception.py | 35 +----------- src/git_autograder/helpers/answers_helper.py | 15 ++--- src/git_autograder/helpers/branch_helper.py | 18 +++--- src/git_autograder/helpers/commit_helper.py | 16 ++---- src/git_autograder/helpers/grader_helper.py | 7 +-- src/git_autograder/repo.py | 58 ++++++-------------- src/git_autograder/test_utils.py | 19 ++++--- 8 files changed, 77 insertions(+), 130 deletions(-) diff --git a/src/git_autograder/autograder.py b/src/git_autograder/autograder.py index 4c31d1b..2d908a3 100644 --- a/src/git_autograder/autograder.py +++ b/src/git_autograder/autograder.py @@ -34,34 +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: - is_local = os.environ.get("is_local", "false") == "true" - exercise_name = os.environ.get("exercise_name") - if exercise_name is None: - raise GitAutograderInvalidStateException( - "Missing exercise_name in environment", None, None, is_local - ) - repo = GitAutograderRepo(is_local=is_local, exercise_name=exercise_name) 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/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/answers_helper.py b/src/git_autograder/helpers/answers_helper.py index f1ac450..2f58a81 100644 --- a/src/git_autograder/helpers/answers_helper.py +++ b/src/git_autograder/helpers/answers_helper.py @@ -1,19 +1,18 @@ from typing import List +from git import Repo + from git_autograder.answers import GitAutograderAnswers from git_autograder.answers.rules import AnswerRule from git_autograder.exception import ( GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) -from git_autograder.repo import GitAutograderRepo class AnswersHelper: - def __init__( - self, ctx: GitAutograderRepo.Context, answers: GitAutograderAnswers - ) -> None: - self.ctx = ctx + def __init__(self, repo: Repo, answers: GitAutograderAnswers) -> None: + self.repo = repo self.answers = answers def validate_question( @@ -23,9 +22,6 @@ def validate_question( if answer is None: raise GitAutograderInvalidStateException( f"Missing question {question} in answers file.", - exercise_name=self.ctx.exercise_name, - is_local=self.ctx.is_local, - started_at=self.ctx.started_at, ) for rule in rules: @@ -34,9 +30,6 @@ def validate_question( except Exception as e: raise GitAutograderWrongAnswerException( [str(e)], - exercise_name=self.ctx.exercise_name, - is_local=self.ctx.is_local, - started_at=self.ctx.started_at, ) return self diff --git a/src/git_autograder/helpers/branch_helper.py b/src/git_autograder/helpers/branch_helper.py index 3ee6e37..504b0d1 100644 --- a/src/git_autograder/helpers/branch_helper.py +++ b/src/git_autograder/helpers/branch_helper.py @@ -1,34 +1,32 @@ from typing import List +from git import Repo + from git_autograder.exception import GitAutograderInvalidStateException -from git_autograder.repo import GitAutograderRepo class BranchHelper: - def __init__(self, ctx: GitAutograderRepo.Context) -> None: - self.ctx = ctx + def __init__(self, repo: Repo) -> None: + self.repo = repo def track_remote_branches(self, remotes: List[str], strict: bool = False) -> None: - if self.ctx.is_local: + if "origin" not in [remote.name for remote in self.repo.remotes]: return tracked = {"main"} - for remote in self.ctx.repo.repo.remote("origin").refs: + 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.ctx.repo.repo.git.checkout("-b", r, f"origin/{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.ctx.exercise_name, - self.ctx.started_at, - self.ctx.is_local, ) def has_branch(self, branch: str) -> bool: - return branch in self.ctx.repo.repo.heads + return branch in self.repo.heads diff --git a/src/git_autograder/helpers/commit_helper.py b/src/git_autograder/helpers/commit_helper.py index 8e7d681..02819a4 100644 --- a/src/git_autograder/helpers/commit_helper.py +++ b/src/git_autograder/helpers/commit_helper.py @@ -1,14 +1,14 @@ from typing import List -from git import Commit +from git import Commit, Repo from git_autograder.exception import GitAutograderInvalidStateException from git_autograder.repo import GitAutograderRepo class CommitHelper: - def __init__(self, ctx: GitAutograderRepo.Context) -> None: - self.ctx = ctx + def __init__(self, repo: Repo) -> None: + self.repo = repo def is_child_commit(self, child: Commit, parent: Commit) -> bool: if child == parent: @@ -23,7 +23,7 @@ def is_child_commit(self, child: Commit, parent: Commit) -> bool: def commits(self, branch: str = "main") -> List[Commit]: """Retrieve the available commits of a given branch.""" commits = [] - for commit in self.ctx.repo.repo.iter_commits(branch): + for commit in self.repo.iter_commits(branch): commits.append(commit) return commits @@ -40,9 +40,6 @@ def start_commit(self, branch: str = "main") -> Commit: if len(commits) == 0: raise GitAutograderInvalidStateException( f"Branch {branch} is missing any commits", - self.ctx.exercise_name, - self.ctx.started_at, - self.ctx.is_local, ) first_commit = commits[-1] @@ -51,7 +48,7 @@ def start_commit(self, branch: str = "main") -> Commit: start_tag_name = f"git-mastery-start-{first_commit_hash[:7]}" start_tag = None - for tag in self.ctx.repo.repo.tags: + for tag in self.repo.tags: if str(tag) == start_tag_name: start_tag = tag break @@ -59,9 +56,6 @@ def start_commit(self, branch: str = "main") -> Commit: if start_tag is None: raise GitAutograderInvalidStateException( f"Branch {branch} is missing the Git Mastery start commit", - self.ctx.exercise_name, - self.ctx.started_at, - self.ctx.is_local, ) return start_tag.commit diff --git a/src/git_autograder/helpers/grader_helper.py b/src/git_autograder/helpers/grader_helper.py index 24299df..4862ae3 100644 --- a/src/git_autograder/helpers/grader_helper.py +++ b/src/git_autograder/helpers/grader_helper.py @@ -1,22 +1,21 @@ from typing import List, Optional, Tuple -from git import Commit +from git import Commit, Repo from git.diff import Lit_change_type from git_autograder.diff import GitAutograderDiff, GitAutograderDiffHelper from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper -from git_autograder.repo import GitAutograderRepo class GraderHelper: def __init__( self, - ctx: GitAutograderRepo.Context, + repo: Repo, branch_helper: BranchHelper, commit_helper: CommitHelper, ) -> None: - self.ctx = ctx + self.repo = repo self.branch_helper = branch_helper self.commit_helper = commit_helper diff --git a/src/git_autograder/repo.py b/src/git_autograder/repo.py index 6b3a9b8..a09b0c6 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -12,19 +12,15 @@ GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) +from git_autograder.helpers.answers_helper import AnswersHelper +from git_autograder.helpers.branch_helper import BranchHelper +from git_autograder.helpers.commit_helper import CommitHelper +from git_autograder.helpers.grader_helper import GraderHelper from git_autograder.output import GitAutograderOutput from git_autograder.status import GitAutograderStatus class GitAutograderRepo: - @dataclass - class Context: - repo: "GitAutograderRepo" - is_local: bool - exercise_name: str - started_at: datetime - repo_path: Optional[str | os.PathLike] = None - def __init__( self, is_local: bool, @@ -32,10 +28,10 @@ def __init__( repo_path: Optional[str | os.PathLike] = None, ) -> None: # 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.started_at = self.__now() self.is_local: bool = is_local - self.__exercise_name = exercise_name - self.__repo_path = ( + self.exercise_name = exercise_name + self.repo_path = ( repo_path if repo_path is not None else Path.cwd().parent / "main" @@ -43,51 +39,31 @@ def __init__( else Path.cwd().parent / "exercises" / exercise_name ) - self.repo: Repo = Repo(self.__repo_path) - self.ctx = self.Context( - repo=self, - started_at=self.__started_at, - is_local=self.is_local, - repo_path=self.__repo_path, - exercise_name=self.__exercise_name, - ) - + self.repo: Repo = Repo(self.repo_path) # Doing this to break the cyclic dependency - from git_autograder.helpers.answers_helper import AnswersHelper - from git_autograder.helpers.branch_helper import BranchHelper - from git_autograder.helpers.commit_helper import CommitHelper - from git_autograder.helpers.grader_helper import GraderHelper - - self.branches: BranchHelper = BranchHelper(self.ctx) - self.commits: CommitHelper = CommitHelper(self.ctx) - self.grader: GraderHelper = GraderHelper(self.ctx, self.branches, self.commits) + self.branches: BranchHelper = BranchHelper(self.repo) + self.commits: CommitHelper = CommitHelper(self.repo) + self.grader: GraderHelper = GraderHelper(self.repo, self.branches, self.commits) self.__answers_parser: Optional[GitAutograderAnswersParser] = None self.__answers: Optional[AnswersHelper] = None - from git_autograder.helpers.answers_helper import AnswersHelper - @property def answers(self) -> AnswersHelper: - from git_autograder.helpers.answers_helper import AnswersHelper - """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" + 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), - exercise_name=self.__exercise_name, - is_local=self.is_local, - started_at=self.__started_at, ) if self.__answers is None: - self.__answers = AnswersHelper(self.ctx, self.__answers_parser.answers) + self.__answers = AnswersHelper(self.repo, self.__answers_parser.answers) return self.__answers @@ -104,8 +80,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, @@ -119,6 +95,4 @@ def to_output( ) def wrong_answer(self, comments: List[str]) -> GitAutograderWrongAnswerException: - return GitAutograderWrongAnswerException( - comments, self.__exercise_name, self.__started_at, self.is_local - ) + return GitAutograderWrongAnswerException(comments) diff --git a/src/git_autograder/test_utils.py b/src/git_autograder/test_utils.py index e8be783..543eb40 100644 --- a/src/git_autograder/test_utils.py +++ b/src/git_autograder/test_utils.py @@ -45,9 +45,10 @@ def setup_autograder( with repo_initializer.initialize() as r: setup(r) output: Optional[GitAutograderOutput] = None + started_at = datetime.now(tz=pytz.UTC) try: autograder = GitAutograderRepo( - is_local=False, exercise_name=exercise_name, repo_path=r.working_dir + is_local=True, exercise_name=exercise_name, repo_path=r.working_dir ) output = grade_func(autograder) except ( @@ -55,20 +56,24 @@ def setup_autograder( 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, ) From 954b6b1004e5c4ac986e1886aff0f200bb894168 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 23:18:38 +0800 Subject: [PATCH 24/99] Bump minor version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index db965b5..c4b4cbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.5.0b1" +version = "3.6.0b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 9bc15b07adab570669fc330f71cf5a979d719263 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 23:19:50 +0800 Subject: [PATCH 25/99] Remove unused improt --- src/git_autograder/helpers/commit_helper.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/git_autograder/helpers/commit_helper.py b/src/git_autograder/helpers/commit_helper.py index 02819a4..a702be8 100644 --- a/src/git_autograder/helpers/commit_helper.py +++ b/src/git_autograder/helpers/commit_helper.py @@ -3,7 +3,6 @@ from git import Commit, Repo from git_autograder.exception import GitAutograderInvalidStateException -from git_autograder.repo import GitAutograderRepo class CommitHelper: From fbbd2d44b0a197d6ab8c21d4dd9e56fc776b66bf Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 23:20:01 +0800 Subject: [PATCH 26/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c4b4cbb..b6e6a5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.0b1" +version = "3.6.1b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 07e124c9c91cd775c9227e0aca6594e49c1ccf09 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 23:48:55 +0800 Subject: [PATCH 27/99] Add .pyi --- src/git_autograder/__init__.pyi | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/git_autograder/__init__.pyi diff --git a/src/git_autograder/__init__.pyi b/src/git_autograder/__init__.pyi new file mode 100644 index 0000000..07252f9 --- /dev/null +++ b/src/git_autograder/__init__.pyi @@ -0,0 +1,14 @@ +__all__ = [ + "autograder", + "setup_autograder", + "set_env", + "GitAutograderRepo", + "GitAutograderStatus", + "GitAutograderOutput", +] + +from .autograder import autograder +from .test_utils import setup_autograder, set_env +from .repo import GitAutograderRepo +from .status import GitAutograderStatus +from .output import GitAutograderOutput From b5a1984b6b4eb38ff4d6ec77c6a81cb3a10d6a6d Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Tue, 8 Apr 2025 23:49:05 +0800 Subject: [PATCH 28/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b6e6a5f..838cab6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.1b1" +version = "3.6.2b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From a8fb0e11ec84127865605643a5e92190cfe18fdd Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 09:19:25 +0800 Subject: [PATCH 29/99] Remove .pyi --- src/git_autograder/__init__.pyi | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 src/git_autograder/__init__.pyi diff --git a/src/git_autograder/__init__.pyi b/src/git_autograder/__init__.pyi deleted file mode 100644 index 07252f9..0000000 --- a/src/git_autograder/__init__.pyi +++ /dev/null @@ -1,14 +0,0 @@ -__all__ = [ - "autograder", - "setup_autograder", - "set_env", - "GitAutograderRepo", - "GitAutograderStatus", - "GitAutograderOutput", -] - -from .autograder import autograder -from .test_utils import setup_autograder, set_env -from .repo import GitAutograderRepo -from .status import GitAutograderStatus -from .output import GitAutograderOutput From c191acf9a4fc34b72d457e64b74759ab222a6837 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 09:19:57 +0800 Subject: [PATCH 30/99] Bump patch version --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 838cab6..4085de4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.2b1" +version = "3.6.3b1" 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.hatch.build.targets.wheel] +packages = ["src/git_autograder"] From de801de56117c9fa42010934ceed796b25868b5f Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 09:34:51 +0800 Subject: [PATCH 31/99] Add __init__ to helpers --- src/git_autograder/helpers/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/git_autograder/helpers/__init__.py diff --git a/src/git_autograder/helpers/__init__.py b/src/git_autograder/helpers/__init__.py new file mode 100644 index 0000000..e69de29 From 928a6ebe47b8d39e857884def54e0aa2dbbbdb35 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 09:35:00 +0800 Subject: [PATCH 32/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4085de4..acab5ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.3b1" +version = "3.6.4b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 817f062c798298dfd9b55fcbaffdf55bf830f019 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 10:23:45 +0800 Subject: [PATCH 33/99] Bump patch version --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index acab5ef..4bbd6d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.4b1" +version = "3.6.5b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" @@ -28,4 +28,5 @@ addopts = ["--import-mode=importlib"] pythonpath = ["src"] [tool.hatch.build.targets.wheel] -packages = ["src/git_autograder"] +src = "src" +packages = ["git_autograder"] From ef4b26525095e5ccac1cc5b2ee5f67c122cfc72a Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 10:25:33 +0800 Subject: [PATCH 34/99] Bump patch version --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4bbd6d6..68b1e5c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.5b1" +version = "3.6.6b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" @@ -28,5 +28,5 @@ addopts = ["--import-mode=importlib"] pythonpath = ["src"] [tool.hatch.build.targets.wheel] -src = "src" +source = "src" packages = ["git_autograder"] From e230a239ecfd7e03e83ca2597facf2689bb5a322 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 10:28:37 +0800 Subject: [PATCH 35/99] Bump patch version --- pyproject.toml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 68b1e5c..f4a50a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.6b1" +version = "3.6.7b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" @@ -28,5 +28,4 @@ addopts = ["--import-mode=importlib"] pythonpath = ["src"] [tool.hatch.build.targets.wheel] -source = "src" -packages = ["git_autograder"] +packages = ["src/git_autograder"] From 48debe48a742b0ce92f3e68e781ef836a46c4984 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 10:43:06 +0800 Subject: [PATCH 36/99] Rename autograder -> decorators --- src/git_autograder/__init__.py | 2 +- src/git_autograder/{autograder.py => decorators.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/git_autograder/{autograder.py => decorators.py} (100%) diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index 07252f9..176574b 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -7,7 +7,7 @@ "GitAutograderOutput", ] -from .autograder import autograder +from .decorators import autograder from .test_utils import setup_autograder, set_env from .repo import GitAutograderRepo from .status import GitAutograderStatus diff --git a/src/git_autograder/autograder.py b/src/git_autograder/decorators.py similarity index 100% rename from src/git_autograder/autograder.py rename to src/git_autograder/decorators.py From f006632f5fe3341213886f3463f2fc8053361c80 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 10:43:18 +0800 Subject: [PATCH 37/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f4a50a4..9eac1ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.7b1" +version = "3.6.8b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From a524387edb21c2f730ffc9950f58391b36d2b02c Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 14:10:18 +0800 Subject: [PATCH 38/99] Bump patch version --- pyproject.toml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9eac1ea..9247ff3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.8b1" +version = "3.6.9b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" @@ -27,5 +27,3 @@ Issues = "https://github.com/git-mastery/git-autograder/issues" addopts = ["--import-mode=importlib"] pythonpath = ["src"] -[tool.hatch.build.targets.wheel] -packages = ["src/git_autograder"] From fe8030d4599f928a480c88740696ce6665573efc Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 14:27:40 +0800 Subject: [PATCH 39/99] Add mypy_path --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 9247ff3..fdd5127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,3 +27,5 @@ Issues = "https://github.com/git-mastery/git-autograder/issues" addopts = ["--import-mode=importlib"] pythonpath = ["src"] +[tool.mypy] +mypy_path = "src" From bab35447d895077625bb765864974d62d01e6ccd Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 14:27:47 +0800 Subject: [PATCH 40/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fdd5127..179cbd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.9b1" +version = "3.6.10b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 4efa8fd7ec2efe7c3bd2478ddbc6a3d7b63fb010 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 15:02:00 +0800 Subject: [PATCH 41/99] Reorder imports --- src/git_autograder/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index 176574b..e7eb189 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -7,8 +7,8 @@ "GitAutograderOutput", ] +from .output import GitAutograderOutput +from .status import GitAutograderStatus +from .repo import GitAutograderRepo from .decorators import autograder from .test_utils import setup_autograder, set_env -from .repo import GitAutograderRepo -from .status import GitAutograderStatus -from .output import GitAutograderOutput From ffde270c37d097e259142bd37f17dbbcf05f0111 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 15:02:07 +0800 Subject: [PATCH 42/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 179cbd5..d785ebd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.10b1" +version = "3.6.11b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 0425a4271883b060e6eabf58f828ee8a349f91cd Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 21:56:55 +0800 Subject: [PATCH 43/99] Add RemoteHelper --- src/git_autograder/helpers/remote_helper.py | 30 +++++++++++++++++++++ src/git_autograder/repo.py | 4 ++- 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 src/git_autograder/helpers/remote_helper.py diff --git a/src/git_autograder/helpers/remote_helper.py b/src/git_autograder/helpers/remote_helper.py new file mode 100644 index 0000000..1455f82 --- /dev/null +++ b/src/git_autograder/helpers/remote_helper.py @@ -0,0 +1,30 @@ +from typing import List, Optional +from git import Remote, Repo + + +class RemoteHelper: + def __init__(self, repo: Repo) -> None: + self.repo = repo + + def track(self, branches: List[str], remote_name: str = "origin") -> None: + origin_remote = self.remote(remote_name) + if origin_remote is None: + return + + tracked = {"main"} + for remote in origin_remote.refs: + for b in branches: + if b not in tracked or f"{remote_name}/{b}" != remote.name: + continue + tracked.add(b) + self.repo.git.checkout("-b", b, f"{remote_name}/{b}") + break + + def remote(self, remote_name: str) -> Optional[Remote]: + for remote in self.repo.remotes: + if remote.name == remote_name: + return remote + return None + + def has_remote(self, remote_name: str) -> bool: + return self.remote(remote_name) is not None diff --git a/src/git_autograder/repo.py b/src/git_autograder/repo.py index a09b0c6..3e81888 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -16,6 +16,7 @@ from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper from git_autograder.helpers.grader_helper import GraderHelper +from git_autograder.helpers.remote_helper import RemoteHelper from git_autograder.output import GitAutograderOutput from git_autograder.status import GitAutograderStatus @@ -40,9 +41,10 @@ def __init__( ) self.repo: Repo = Repo(self.repo_path) - # Doing this to break the cyclic dependency + self.branches: BranchHelper = BranchHelper(self.repo) self.commits: CommitHelper = CommitHelper(self.repo) + self.remotes: RemoteHelper = RemoteHelper(self.repo) self.grader: GraderHelper = GraderHelper(self.repo, self.branches, self.commits) self.__answers_parser: Optional[GitAutograderAnswersParser] = None self.__answers: Optional[AnswersHelper] = None From f0533dab8e100dcc7613ed34a86fb1869064e8e1 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 21:57:12 +0800 Subject: [PATCH 44/99] Clean up helpers --- src/git_autograder/helpers/branch_helper.py | 33 ++++++--------------- src/git_autograder/helpers/commit_helper.py | 6 ++-- src/git_autograder/helpers/grader_helper.py | 1 + 3 files changed, 13 insertions(+), 27 deletions(-) diff --git a/src/git_autograder/helpers/branch_helper.py b/src/git_autograder/helpers/branch_helper.py index 504b0d1..a7b219b 100644 --- a/src/git_autograder/helpers/branch_helper.py +++ b/src/git_autograder/helpers/branch_helper.py @@ -1,32 +1,17 @@ -from typing import List +from typing import Optional -from git import Repo - -from git_autograder.exception import GitAutograderInvalidStateException +from git import Head, Repo class BranchHelper: def __init__(self, repo: Repo) -> None: self.repo = repo - def track_remote_branches(self, remotes: List[str], strict: bool = False) -> None: - if "origin" not in [remote.name for remote in self.repo.remotes]: - 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", - ) + def branch(self, branch_name: str) -> Optional[Head]: + for head in self.repo.heads: + if head.name == branch_name: + return head + return None - def has_branch(self, branch: str) -> bool: - return branch in self.repo.heads + def has_branch(self, branch_name: str) -> bool: + return self.branch(branch_name) is not None diff --git a/src/git_autograder/helpers/commit_helper.py b/src/git_autograder/helpers/commit_helper.py index a702be8..257e543 100644 --- a/src/git_autograder/helpers/commit_helper.py +++ b/src/git_autograder/helpers/commit_helper.py @@ -9,13 +9,13 @@ class CommitHelper: def __init__(self, repo: Repo) -> None: self.repo = repo - def is_child_commit(self, child: Commit, parent: Commit) -> bool: + def is_child(self, child: Commit, parent: Commit) -> bool: if child == parent: return True res = False - for parent in child.parents: - res |= self.is_child_commit(parent, parent) + for child_parent in child.parents: + res |= self.is_child(child_parent, parent) return res diff --git a/src/git_autograder/helpers/grader_helper.py b/src/git_autograder/helpers/grader_helper.py index 4862ae3..e892461 100644 --- a/src/git_autograder/helpers/grader_helper.py +++ b/src/git_autograder/helpers/grader_helper.py @@ -15,6 +15,7 @@ def __init__( branch_helper: BranchHelper, commit_helper: CommitHelper, ) -> None: + """General autograder behavior that combines the behavior across Git objects.""" self.repo = repo self.branch_helper = branch_helper self.commit_helper = commit_helper From 7c3d352e0a1953dd1225634c0416bc3bdd8ba185 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 21:57:23 +0800 Subject: [PATCH 45/99] Fix order of imports in __init__ Removes cyclic dependencies --- src/git_autograder/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index e7eb189..be2a4a8 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -7,8 +7,8 @@ "GitAutograderOutput", ] -from .output import GitAutograderOutput from .status import GitAutograderStatus +from .output import GitAutograderOutput from .repo import GitAutograderRepo from .decorators import autograder from .test_utils import setup_autograder, set_env From 136f20f503f212fe5f29f3459a6f25c5a6a39b10 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 21:57:36 +0800 Subject: [PATCH 46/99] Bump minor version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d785ebd..6639dc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.6.11b1" +version = "3.7.0b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From fa6d2a84ff9a025c0bb637dfb87ede7a5743e685 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 22:10:02 +0800 Subject: [PATCH 47/99] Set setup function to optional Some unit tests don't need additional setup, so we avoid doing that --- src/git_autograder/test_utils.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/git_autograder/test_utils.py b/src/git_autograder/test_utils.py index 543eb40..f7eb981 100644 --- a/src/git_autograder/test_utils.py +++ b/src/git_autograder/test_utils.py @@ -38,12 +38,13 @@ def setup_autograder( 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: From 76eb62d5f797c550376fc7c175959bcfc82b78f2 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Wed, 9 Apr 2025 22:10:12 +0800 Subject: [PATCH 48/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6639dc5..f2056a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.7.0b1" +version = "3.7.1b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 08ba683a5a16af0a111e4c8ea6dbb95b0327d257 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 12:51:37 +0800 Subject: [PATCH 49/99] Set all answer rule errors to constant Easier to test this way --- .../answers/rules/contains_list_rule.py | 9 +++++---- .../answers/rules/contains_value_rule.py | 6 +++--- .../answers/rules/has_exact_list_rule.py | 13 +++++++------ .../answers/rules/has_exact_value_rule.py | 4 +++- src/git_autograder/answers/rules/not_empty_rule.py | 4 +++- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/git_autograder/answers/rules/contains_list_rule.py b/src/git_autograder/answers/rules/contains_list_rule.py index 3f8469a..a9a3dcc 100644 --- a/src/git_autograder/answers/rules/contains_list_rule.py +++ b/src/git_autograder/answers/rules/contains_list_rule.py @@ -5,6 +5,9 @@ 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: @@ -22,8 +25,6 @@ def apply(self, answer: GitAutograderAnswersRecord) -> None: else answer.answer_as_list() ) if self.subset and not all([v in expected for v in given]): - raise Exception(f"Answer for {answer.question} contains an invalid item.") + raise Exception(self.INVALID_ITEM.format(question=answer.question)) elif not any([v in expected for v in given]): - raise Exception( - f"Answer for {answer.question} does not contain any valid items." - ) + 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 index 50deae3..6489bc3 100644 --- a/src/git_autograder/answers/rules/contains_value_rule.py +++ b/src/git_autograder/answers/rules/contains_value_rule.py @@ -3,6 +3,8 @@ 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 @@ -11,6 +13,4 @@ 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( - f"Answer for {answer.question} does not contain the right answer." - ) + 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 index 1000f35..ba2a1be 100644 --- a/src/git_autograder/answers/rules/has_exact_list_rule.py +++ b/src/git_autograder/answers/rules/has_exact_list_rule.py @@ -5,6 +5,11 @@ 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: @@ -22,10 +27,6 @@ def apply(self, answer: GitAutograderAnswersRecord) -> None: else answer.answer_as_list() ) if self.ordered and expected != given: - raise Exception( - f"Answer for {answer.question} does not contain all of the right answers. Ensure that they follow the order specified." - ) + raise Exception(self.INCORRECT_ORDERED.format(question=answer.question)) elif set(expected).intersection(set(given)) != len(expected): - raise Exception( - f"Answer for {answer.question} does not contain all of the right answers." - ) + 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 index f779078..7ca0071 100644 --- a/src/git_autograder/answers/rules/has_exact_value_rule.py +++ b/src/git_autograder/answers/rules/has_exact_value_rule.py @@ -3,6 +3,8 @@ 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 @@ -12,4 +14,4 @@ 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(f"Answer for {answer.question} is not right.") + 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 index 4508d8b..e805ef8 100644 --- a/src/git_autograder/answers/rules/not_empty_rule.py +++ b/src/git_autograder/answers/rules/not_empty_rule.py @@ -3,6 +3,8 @@ class NotEmptyRule(AnswerRule): + EMPTY = "Answer for {question} is empty." + def apply(self, answer: GitAutograderAnswersRecord) -> None: if answer.answer.strip() != "": - raise Exception(f"Answer for {answer.question} is empty.") + raise Exception(self.EMPTY.format(question=answer.question)) From 85230fb8a666f42cd28a6dcee96a261690b7899e Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 13:49:53 +0800 Subject: [PATCH 50/99] Move diff to subpackage Reduces clutter --- src/git_autograder/diff/diff.py | 17 +++++++++++++++++ .../{diff.py => diff/diff_helper.py} | 18 ++++-------------- 2 files changed, 21 insertions(+), 14 deletions(-) create mode 100644 src/git_autograder/diff/diff.py rename src/git_autograder/{diff.py => diff/diff_helper.py} (81%) diff --git a/src/git_autograder/diff/diff.py b/src/git_autograder/diff/diff.py new file mode 100644 index 0000000..152e6d3 --- /dev/null +++ b/src/git_autograder/diff/diff.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from typing import Iterator, Optional + +from difflib_parser.difflib_parser import DiffParser +from git import Commit, DiffIndex +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[DiffParser] diff --git a/src/git_autograder/diff.py b/src/git_autograder/diff/diff_helper.py similarity index 81% rename from src/git_autograder/diff.py rename to src/git_autograder/diff/diff_helper.py index 06c63bb..c1a23ee 100644 --- a/src/git_autograder/diff.py +++ b/src/git_autograder/diff/diff_helper.py @@ -1,20 +1,10 @@ -from dataclasses import dataclass -from typing import Iterator, Optional +from typing import Iterator from difflib_parser.difflib_parser import DiffParser -from git import Commit, DiffIndex -from git.diff import Diff, Lit_change_type +from git import Commit, Diff, DiffIndex +from git.diff import 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[DiffParser] +from git_autograder.diff.diff import GitAutograderDiff class GitAutograderDiffHelper: From 2f754c060c956efd987257da21de8ae6700e62d4 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 14:39:17 +0800 Subject: [PATCH 51/99] Move helper functions in Helper to individual wrapper classes This way, we have a far more idiomatic way of declaring rules and grading features, as opposed to passing a bunch of parameters --- src/git_autograder/__init__.py | 6 ++ src/git_autograder/answers/answers.py | 27 ++++- src/git_autograder/answers/answers_record.py | 15 +++ src/git_autograder/branch.py | 101 +++++++++++++++++++ src/git_autograder/commit.py | 49 +++++++++ src/git_autograder/diff/__init__.py | 4 + src/git_autograder/diff/diff.py | 3 +- src/git_autograder/diff/diff_helper.py | 13 ++- src/git_autograder/helpers/__init__.py | 5 + src/git_autograder/helpers/answers_helper.py | 35 ------- src/git_autograder/helpers/branch_helper.py | 8 +- src/git_autograder/helpers/commit_helper.py | 73 ++------------ src/git_autograder/helpers/grader_helper.py | 64 ------------ src/git_autograder/helpers/remote_helper.py | 25 ++--- src/git_autograder/remote.py | 19 ++++ src/git_autograder/repo.py | 13 ++- 16 files changed, 261 insertions(+), 199 deletions(-) create mode 100644 src/git_autograder/branch.py create mode 100644 src/git_autograder/commit.py create mode 100644 src/git_autograder/diff/__init__.py delete mode 100644 src/git_autograder/helpers/answers_helper.py delete mode 100644 src/git_autograder/helpers/grader_helper.py create mode 100644 src/git_autograder/remote.py diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index be2a4a8..15e27ba 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -5,10 +5,16 @@ "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 diff --git a/src/git_autograder/answers/answers.py b/src/git_autograder/answers/answers.py index c91c977..8c0fc1f 100644 --- a/src/git_autograder/answers/answers.py +++ b/src/git_autograder/answers/answers.py @@ -2,10 +2,13 @@ from typing import List, Optional from git_autograder.answers.answers_record import GitAutograderAnswersRecord +from git_autograder.exception import GitAutograderInvalidStateException @dataclass class GitAutograderAnswers: + MISSING_QUESTION = "Missing question {question} in answers file." + questions: List[str] answers: List[str] @@ -26,8 +29,30 @@ def __getitem__(self, key: int) -> GitAutograderAnswersRecord: def __len__(self) -> int: return len(self.questions) - def get_by_question(self, question: str) -> Optional[GitAutograderAnswersRecord]: + 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 diff --git a/src/git_autograder/answers/answers_record.py b/src/git_autograder/answers/answers_record.py index bd46527..25267cd 100644 --- a/src/git_autograder/answers/answers_record.py +++ b/src/git_autograder/answers/answers_record.py @@ -1,6 +1,9 @@ from dataclasses import dataclass from typing import List, Tuple +from git_autograder.answers.rules.answer_rule import AnswerRule +from git_autograder.exception import GitAutograderWrongAnswerException + @dataclass class GitAutograderAnswersRecord: @@ -29,3 +32,15 @@ def answer_as_list(self) -> List[str]: if acc.strip() != "": points.append(acc.strip()[::]) return points + + def validate(self, rules: List[AnswerRule]) -> None: + """ + Validates that a given GitAutograderAnswersRecord passes a set of rules. + + :raises GitAutograderWrongAnswerException: when a rule is violated. + """ + for rule in rules: + try: + rule.apply(self) + except Exception as e: + raise GitAutograderWrongAnswerException([str(e)]) diff --git a/src/git_autograder/branch.py b/src/git_autograder/branch.py new file mode 100644 index 0000000..f31eed0 --- /dev/null +++ b/src/git_autograder/branch.py @@ -0,0 +1,101 @@ +from typing import List + +from git import Head + +from git_autograder.commit import GitAutograderCommit +from git_autograder.exception import GitAutograderInvalidStateException +from git_autograder.diff import GitAutograderDiffHelper + + +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 + + @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 + + 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.""" + latest_commit = self.user_commits[-1] + diff_helper = GitAutograderDiffHelper(self.start_commit, 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.""" + latest_commit = self.user_commits[-1] + diff_helper = GitAutograderDiffHelper(self.start_commit, latest_commit) + for diff in diff_helper.iter_changes("A"): + if diff.edited_file_path == file_path: + return True + return False diff --git a/src/git_autograder/commit.py b/src/git_autograder/commit.py new file mode 100644 index 0000000..b3afe9c --- /dev/null +++ b/src/git_autograder/commit.py @@ -0,0 +1,49 @@ +from typing import List, Optional, Tuple, Union + +from git import Commit, Stats +from git.diff import Lit_change_type + +from git_autograder.diff.diff import GitAutograderDiff +from git_autograder.diff.diff_helper import GitAutograderDiffHelper + + +class GitAutograderCommit: + def __init__(self, commit: Commit) -> None: + self.commit = commit + + @property + def hexsha(self) -> str: + return self.commit.hexsha + + @property + def stats(self) -> Stats: + return self.commit.stats + + 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 get_file_diff( + self, other: 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(self, other) + 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 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 index 152e6d3..489f1f9 100644 --- a/src/git_autograder/diff/diff.py +++ b/src/git_autograder/diff/diff.py @@ -1,8 +1,7 @@ from dataclasses import dataclass -from typing import Iterator, Optional +from typing import Optional from difflib_parser.difflib_parser import DiffParser -from git import Commit, DiffIndex from git.diff import Diff, Lit_change_type diff --git a/src/git_autograder/diff/diff_helper.py b/src/git_autograder/diff/diff_helper.py index c1a23ee..ce22649 100644 --- a/src/git_autograder/diff/diff_helper.py +++ b/src/git_autograder/diff/diff_helper.py @@ -1,15 +1,22 @@ -from typing import Iterator +from typing import Iterator, Union from difflib_parser.difflib_parser import DiffParser 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: Commit, b: Commit) -> None: - self.diff_index: DiffIndex[Diff] = a.diff(b) + def __init__( + self, + a: Union[Commit, GitAutograderCommit], + b: Union[Commit, GitAutograderCommit], + ) -> None: + a_commit = a if isinstance(a, Commit) else a.commit + b_commit = b if isinstance(b, Commit) else b.commit + self.diff_index: DiffIndex[Diff] = a_commit.diff(b_commit) def iter_changes(self, change_type: Lit_change_type) -> Iterator[GitAutograderDiff]: for change in self.diff_index.iter_change_type(change_type): diff --git a/src/git_autograder/helpers/__init__.py b/src/git_autograder/helpers/__init__.py index e69de29..ea572ad 100644 --- a/src/git_autograder/helpers/__init__.py +++ b/src/git_autograder/helpers/__init__.py @@ -0,0 +1,5 @@ +__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper"] + +from .branch_helper import BranchHelper +from .commit_helper import CommitHelper +from .remote_helper import RemoteHelper diff --git a/src/git_autograder/helpers/answers_helper.py b/src/git_autograder/helpers/answers_helper.py deleted file mode 100644 index 2f58a81..0000000 --- a/src/git_autograder/helpers/answers_helper.py +++ /dev/null @@ -1,35 +0,0 @@ -from typing import List - -from git import Repo - -from git_autograder.answers import GitAutograderAnswers -from git_autograder.answers.rules import AnswerRule -from git_autograder.exception import ( - GitAutograderInvalidStateException, - GitAutograderWrongAnswerException, -) - - -class AnswersHelper: - def __init__(self, repo: Repo, answers: GitAutograderAnswers) -> None: - self.repo = repo - self.answers = answers - - def validate_question( - self, question: str, rules: List[AnswerRule] - ) -> "AnswersHelper": - answer = self.answers.get_by_question(question) - if answer is None: - raise GitAutograderInvalidStateException( - f"Missing question {question} in answers file.", - ) - - for rule in rules: - try: - rule.apply(answer) - except Exception as e: - raise GitAutograderWrongAnswerException( - [str(e)], - ) - - return self diff --git a/src/git_autograder/helpers/branch_helper.py b/src/git_autograder/helpers/branch_helper.py index a7b219b..9742572 100644 --- a/src/git_autograder/helpers/branch_helper.py +++ b/src/git_autograder/helpers/branch_helper.py @@ -1,16 +1,18 @@ from typing import Optional -from git import Head, Repo +from git import Repo + +from git_autograder.branch import GitAutograderBranch class BranchHelper: def __init__(self, repo: Repo) -> None: self.repo = repo - def branch(self, branch_name: str) -> Optional[Head]: + def branch(self, branch_name: str) -> Optional[GitAutograderBranch]: for head in self.repo.heads: if head.name == branch_name: - return head + return GitAutograderBranch(head) return None def has_branch(self, branch_name: str) -> bool: diff --git a/src/git_autograder/helpers/commit_helper.py b/src/git_autograder/helpers/commit_helper.py index 257e543..77ed4ae 100644 --- a/src/git_autograder/helpers/commit_helper.py +++ b/src/git_autograder/helpers/commit_helper.py @@ -1,74 +1,15 @@ -from typing import List +from typing import Optional, Union -from git import Commit, Repo +from git import Repo +from git.types import Commit_ish -from git_autograder.exception import GitAutograderInvalidStateException +from git_autograder.commit import GitAutograderCommit class CommitHelper: def __init__(self, repo: Repo) -> None: self.repo = repo - def is_child(self, child: Commit, parent: Commit) -> bool: - if child == parent: - return True - - res = False - for child_parent in child.parents: - res |= self.is_child(child_parent, parent) - - return res - - 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. - """ - commits = self.commits(branch) - - if len(commits) == 0: - raise GitAutograderInvalidStateException( - f"Branch {branch} is missing any commits", - ) - - 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.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", - ) - - 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 commit(self, rev: Optional[Union[Commit_ish, str]]) -> GitAutograderCommit: + c = self.repo.commit(rev) + return GitAutograderCommit(c) diff --git a/src/git_autograder/helpers/grader_helper.py b/src/git_autograder/helpers/grader_helper.py deleted file mode 100644 index e892461..0000000 --- a/src/git_autograder/helpers/grader_helper.py +++ /dev/null @@ -1,64 +0,0 @@ -from typing import List, Optional, Tuple - -from git import Commit, Repo -from git.diff import Lit_change_type - -from git_autograder.diff import GitAutograderDiff, GitAutograderDiffHelper -from git_autograder.helpers.branch_helper import BranchHelper -from git_autograder.helpers.commit_helper import CommitHelper - - -class GraderHelper: - def __init__( - self, - repo: Repo, - branch_helper: BranchHelper, - commit_helper: CommitHelper, - ) -> None: - """General autograder behavior that combines the behavior across Git objects.""" - self.repo = repo - self.branch_helper = branch_helper - self.commit_helper = commit_helper - - def has_non_empty_commits(self, branch: str = "main") -> bool: - """Returns if a given branch has any non-empty commits.""" - for commit in self.commit_helper.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.commit_helper.user_commits(branch)[-1] - diff_helper = GitAutograderDiffHelper( - self.commit_helper.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.commit_helper.user_commits(branch)[-1] - diff_helper = GitAutograderDiffHelper( - self.commit_helper.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 diff --git a/src/git_autograder/helpers/remote_helper.py b/src/git_autograder/helpers/remote_helper.py index 1455f82..06a53b7 100644 --- a/src/git_autograder/helpers/remote_helper.py +++ b/src/git_autograder/helpers/remote_helper.py @@ -1,29 +1,18 @@ -from typing import List, Optional -from git import Remote, Repo +from typing import Optional + +from git import Repo + +from git_autograder.remote import GitAutograderRemote class RemoteHelper: def __init__(self, repo: Repo) -> None: self.repo = repo - def track(self, branches: List[str], remote_name: str = "origin") -> None: - origin_remote = self.remote(remote_name) - if origin_remote is None: - return - - tracked = {"main"} - for remote in origin_remote.refs: - for b in branches: - if b not in tracked or f"{remote_name}/{b}" != remote.name: - continue - tracked.add(b) - self.repo.git.checkout("-b", b, f"{remote_name}/{b}") - break - - def remote(self, remote_name: str) -> Optional[Remote]: + def remote(self, remote_name: str) -> Optional[GitAutograderRemote]: for remote in self.repo.remotes: if remote.name == remote_name: - return remote + return GitAutograderRemote(remote) return None def has_remote(self, remote_name: str) -> bool: diff --git a/src/git_autograder/remote.py b/src/git_autograder/remote.py new file mode 100644 index 0000000..1e752aa --- /dev/null +++ b/src/git_autograder/remote.py @@ -0,0 +1,19 @@ +from typing import List +from git import Remote + + +class GitAutograderRemote: + def __init__(self, remote: Remote) -> None: + self.remote = 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 3e81888..ba49b3b 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -7,15 +7,14 @@ import pytz from git import Repo +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.helpers.answers_helper import AnswersHelper from git_autograder.helpers.branch_helper import BranchHelper from git_autograder.helpers.commit_helper import CommitHelper -from git_autograder.helpers.grader_helper import GraderHelper from git_autograder.helpers.remote_helper import RemoteHelper from git_autograder.output import GitAutograderOutput from git_autograder.status import GitAutograderStatus @@ -28,7 +27,8 @@ def __init__( exercise_name: str, repo_path: Optional[str | os.PathLike] = None, ) -> None: - # TODO: We should not be starting the grading at the point of initializing, but we're keeping this because of the exception system + # 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 @@ -45,12 +45,11 @@ def __init__( self.branches: BranchHelper = BranchHelper(self.repo) self.commits: CommitHelper = CommitHelper(self.repo) self.remotes: RemoteHelper = RemoteHelper(self.repo) - self.grader: GraderHelper = GraderHelper(self.repo, self.branches, self.commits) self.__answers_parser: Optional[GitAutograderAnswersParser] = None - self.__answers: Optional[AnswersHelper] = None + self.__answers: Optional[GitAutograderAnswers] = None @property - def answers(self) -> AnswersHelper: + 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 @@ -65,7 +64,7 @@ def answers(self) -> AnswersHelper: ) if self.__answers is None: - self.__answers = AnswersHelper(self.repo, self.__answers_parser.answers) + self.__answers = self.__answers_parser.answers return self.__answers From c0ce45acb997a34caba2eb58b47fb3b9fb1589f1 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 14:42:23 +0800 Subject: [PATCH 52/99] Bump minor version Changes to overall interface of performing grading --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f2056a4..267026b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.7.1b1" +version = "3.8.0b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From a295f2dcd582c2390449da8903f980925ee55953 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 14:48:04 +0800 Subject: [PATCH 53/99] Add _or_none variant for BranchHelper --- src/git_autograder/helpers/branch_helper.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/git_autograder/helpers/branch_helper.py b/src/git_autograder/helpers/branch_helper.py index 9742572..f11c813 100644 --- a/src/git_autograder/helpers/branch_helper.py +++ b/src/git_autograder/helpers/branch_helper.py @@ -3,17 +3,28 @@ 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(self, branch_name: str) -> Optional[GitAutograderBranch]: + 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) -> Optional[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(branch_name) is not None + return self.branch_or_none(branch_name) is not None From 37e5ab2ef84f6a23fd6601603da7bdbb399aac42 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 14:48:09 +0800 Subject: [PATCH 54/99] Add _or_none variant for RemoteHelper --- src/git_autograder/helpers/remote_helper.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/git_autograder/helpers/remote_helper.py b/src/git_autograder/helpers/remote_helper.py index 06a53b7..456c055 100644 --- a/src/git_autograder/helpers/remote_helper.py +++ b/src/git_autograder/helpers/remote_helper.py @@ -2,18 +2,29 @@ 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(self, remote_name: str) -> Optional[GitAutograderRemote]: + 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(remote_name) is not None + return self.remote_or_none(remote_name) is not None From 1d6bdbb67afaa33a060667b2300c3ff978c71659 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 14:49:11 +0800 Subject: [PATCH 55/99] Update test case --- tests/test_answers_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_answers_parser.py b/tests/test_answers_parser.py index df7fb48..81d1f22 100644 --- a/tests/test_answers_parser.py +++ b/tests/test_answers_parser.py @@ -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" From 724365b90511ce24e92db16aca97226de69e8566 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 14:49:30 +0800 Subject: [PATCH 56/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 267026b..d2dccc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.8.0b1" +version = "3.8.1b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 14397af68df76005ce39ef62fe1dbae04bb242d8 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 14:51:02 +0800 Subject: [PATCH 57/99] Fix type signature --- src/git_autograder/helpers/branch_helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/git_autograder/helpers/branch_helper.py b/src/git_autograder/helpers/branch_helper.py index f11c813..07da692 100644 --- a/src/git_autograder/helpers/branch_helper.py +++ b/src/git_autograder/helpers/branch_helper.py @@ -18,7 +18,7 @@ def branch_or_none(self, branch_name: str) -> Optional[GitAutograderBranch]: return GitAutograderBranch(head) return None - def branch(self, branch_name: str) -> Optional[GitAutograderBranch]: + def branch(self, branch_name: str) -> GitAutograderBranch: b = self.branch_or_none(branch_name) if b is None: raise GitAutograderInvalidStateException( From 314a21084ab38f27a726c59003969fe70c72c48a Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 14:51:10 +0800 Subject: [PATCH 58/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d2dccc6..c21c8d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.8.1b1" +version = "3.8.2b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 4a712fc90328ab7b5427942c43cacefbcc2e4161 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 15:33:58 +0800 Subject: [PATCH 59/99] Add has_deleted and has_added line helper methods --- src/git_autograder/diff/diff.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/git_autograder/diff/diff.py b/src/git_autograder/diff/diff.py index 489f1f9..5f7ea61 100644 --- a/src/git_autograder/diff/diff.py +++ b/src/git_autograder/diff/diff.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Optional -from difflib_parser.difflib_parser import DiffParser +from difflib_parser.difflib_parser import DiffCode, DiffParser from git.diff import Diff, Lit_change_type @@ -14,3 +14,23 @@ class GitAutograderDiff: original_file: Optional[str] edited_file: Optional[str] diff_parser: Optional[DiffParser] + + 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 From 725a319a15af1570e3286eae85dd378a0d790461 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 15:34:10 +0800 Subject: [PATCH 60/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c21c8d9..369edec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.8.2b1" +version = "3.8.3b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 862192f5919be8a05ac9966da1fcc2a73d72d591 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 15:40:40 +0800 Subject: [PATCH 61/99] Add latest_commit property --- src/git_autograder/branch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/git_autograder/branch.py b/src/git_autograder/branch.py index f31eed0..5e18e22 100644 --- a/src/git_autograder/branch.py +++ b/src/git_autograder/branch.py @@ -75,6 +75,10 @@ def user_commits(self) -> List[GitAutograderCommit]: 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: From fe231969cc8c55020c3e44d3cfc24a4d09499aa4 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 15:40:48 +0800 Subject: [PATCH 62/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 369edec..b5381a1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.8.3b1" +version = "3.8.4b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 24277f9ea0073840b734e01942eb986fbd921efe Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 15:42:07 +0800 Subject: [PATCH 63/99] Use latest_commit property --- src/git_autograder/branch.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/git_autograder/branch.py b/src/git_autograder/branch.py index 5e18e22..3ccd637 100644 --- a/src/git_autograder/branch.py +++ b/src/git_autograder/branch.py @@ -88,8 +88,7 @@ def has_non_empty_commits(self) -> bool: def has_edited_file(self, file_path: str) -> bool: """Returns if a given file has been edited in a given branch.""" - latest_commit = self.user_commits[-1] - diff_helper = GitAutograderDiffHelper(self.start_commit, latest_commit) + 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 @@ -97,8 +96,7 @@ def has_edited_file(self, file_path: str) -> bool: def has_added_file(self, file_path: str) -> bool: """Returns if a given file has been added in a given branch.""" - latest_commit = self.user_commits[-1] - diff_helper = GitAutograderDiffHelper(self.start_commit, latest_commit) + 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 From 98a51ebe07c32e5a99e6d59905cb3af3547b8be2 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 15:42:16 +0800 Subject: [PATCH 64/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b5381a1..a02e6b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.8.4b1" +version = "3.8.5b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 85d202c16bce97cbe6a66a39d603c39975f7fa28 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 16:02:00 +0800 Subject: [PATCH 65/99] Add assert_output --- src/git_autograder/test_utils.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/git_autograder/test_utils.py b/src/git_autograder/test_utils.py index f7eb981..9545a8f 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 @@ -81,3 +81,14 @@ def setup_autograder( 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 + ) From 669efb6600881225050d53285875d5628113ecfd Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 17:05:14 +0800 Subject: [PATCH 66/99] Add assert_output to __init__ --- src/git_autograder/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index 15e27ba..50a7ffa 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -2,6 +2,7 @@ "autograder", "setup_autograder", "set_env", + "assert_output", "GitAutograderRepo", "GitAutograderStatus", "GitAutograderOutput", @@ -17,4 +18,4 @@ from .branch import GitAutograderBranch from .remote import GitAutograderRemote from .decorators import autograder -from .test_utils import setup_autograder, set_env +from .test_utils import setup_autograder, set_env, assert_output From a4331a291c40c6fdee4eead9214c1eacd0971a75 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 17:05:24 +0800 Subject: [PATCH 67/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a02e6b3..6324200 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.8.5b1" +version = "3.8.6b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 14f71eec03fa388b9ce2788a34e730033243072a Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 17:20:55 +0800 Subject: [PATCH 68/99] Fix circular imports --- src/git_autograder/answers/answers.py | 24 +++++++++++++++++++- src/git_autograder/answers/answers_record.py | 15 ------------ 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/src/git_autograder/answers/answers.py b/src/git_autograder/answers/answers.py index 8c0fc1f..263cf81 100644 --- a/src/git_autograder/answers/answers.py +++ b/src/git_autograder/answers/answers.py @@ -2,7 +2,11 @@ from typing import List, Optional from git_autograder.answers.answers_record import GitAutograderAnswersRecord -from git_autograder.exception import GitAutograderInvalidStateException +from git_autograder.answers.rules.answer_rule import AnswerRule +from git_autograder.exception import ( + GitAutograderInvalidStateException, + GitAutograderWrongAnswerException, +) @dataclass @@ -56,3 +60,21 @@ def question(self, question: str) -> GitAutograderAnswersRecord: 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_record.py b/src/git_autograder/answers/answers_record.py index 25267cd..bd46527 100644 --- a/src/git_autograder/answers/answers_record.py +++ b/src/git_autograder/answers/answers_record.py @@ -1,9 +1,6 @@ from dataclasses import dataclass from typing import List, Tuple -from git_autograder.answers.rules.answer_rule import AnswerRule -from git_autograder.exception import GitAutograderWrongAnswerException - @dataclass class GitAutograderAnswersRecord: @@ -32,15 +29,3 @@ def answer_as_list(self) -> List[str]: if acc.strip() != "": points.append(acc.strip()[::]) return points - - def validate(self, rules: List[AnswerRule]) -> None: - """ - Validates that a given GitAutograderAnswersRecord passes a set of rules. - - :raises GitAutograderWrongAnswerException: when a rule is violated. - """ - for rule in rules: - try: - rule.apply(self) - except Exception as e: - raise GitAutograderWrongAnswerException([str(e)]) From 3b2a66675f8825933a9d3be95dd1bd5ccedf511a Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 17:25:54 +0800 Subject: [PATCH 69/99] Bump minor version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 6324200..c2bd83b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.8.6b1" +version = "3.9.0b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 71ad72c02e78a57239ca828101bc0a6cbe408f21 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 18:44:24 +0800 Subject: [PATCH 70/99] Move get_file_diff as helper method --- src/git_autograder/commit.py | 20 +----------------- src/git_autograder/diff/diff_helper.py | 28 +++++++++++++++++++++++--- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/src/git_autograder/commit.py b/src/git_autograder/commit.py index b3afe9c..95a2fc9 100644 --- a/src/git_autograder/commit.py +++ b/src/git_autograder/commit.py @@ -1,10 +1,6 @@ -from typing import List, Optional, Tuple, Union +from typing import Union from git import Commit, Stats -from git.diff import Lit_change_type - -from git_autograder.diff.diff import GitAutograderDiff -from git_autograder.diff.diff_helper import GitAutograderDiffHelper class GitAutograderCommit: @@ -33,17 +29,3 @@ def _is_child(child: Commit, parent: Commit) -> bool: return _is_child( self.commit, parent if isinstance(parent, Commit) else parent.commit ) - - def get_file_diff( - self, other: 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(self, other) - 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 diff --git a/src/git_autograder/diff/diff_helper.py b/src/git_autograder/diff/diff_helper.py index ce22649..3dc9fee 100644 --- a/src/git_autograder/diff/diff_helper.py +++ b/src/git_autograder/diff/diff_helper.py @@ -1,4 +1,4 @@ -from typing import Iterator, Union +from typing import Iterator, List, Optional, Tuple, Union from difflib_parser.difflib_parser import DiffParser from git import Commit, Diff, DiffIndex @@ -14,10 +14,32 @@ def __init__( a: Union[Commit, GitAutograderCommit], b: Union[Commit, GitAutograderCommit], ) -> None: - a_commit = a if isinstance(a, Commit) else a.commit - b_commit = b if isinstance(b, Commit) else b.commit + 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 From bb0f9bce7eb407b8ed066c669ac073c469c5f6d2 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 18:44:41 +0800 Subject: [PATCH 71/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c2bd83b..e4b1a40 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.9.0b1" +version = "3.9.1b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From e34e0382a487d93766f8ef19afb7e74f29f5a9b0 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 18:55:21 +0800 Subject: [PATCH 72/99] Add logging --- src/git_autograder/test_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/git_autograder/test_utils.py b/src/git_autograder/test_utils.py index 9545a8f..9b802bd 100644 --- a/src/git_autograder/test_utils.py +++ b/src/git_autograder/test_utils.py @@ -56,6 +56,7 @@ def setup_autograder( GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) as e: + print(e) output = GitAutograderOutput( exercise_name=exercise_name, started_at=started_at, @@ -69,6 +70,7 @@ def setup_autograder( ), ) except Exception as e: + print(e) # Unexpected exception output = GitAutograderOutput( exercise_name=exercise_name, From f657f18787135fbc53dc0953263005194d21a8d9 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 18:55:32 +0800 Subject: [PATCH 73/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e4b1a40..10cf997 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.9.1b1" +version = "3.9.2b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 4122a94fa1f671b0d0db1d0d2d7eca2e0fdbcde3 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 19:09:41 +0800 Subject: [PATCH 74/99] Add __eq__ for wrapper classes --- src/git_autograder/branch.py | 9 +++++++-- src/git_autograder/commit.py | 7 ++++++- src/git_autograder/remote.py | 8 +++++++- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/src/git_autograder/branch.py b/src/git_autograder/branch.py index 3ccd637..e41fe0d 100644 --- a/src/git_autograder/branch.py +++ b/src/git_autograder/branch.py @@ -1,10 +1,10 @@ -from typing import List +from typing import Any, List from git import Head from git_autograder.commit import GitAutograderCommit -from git_autograder.exception import GitAutograderInvalidStateException from git_autograder.diff import GitAutograderDiffHelper +from git_autograder.exception import GitAutograderInvalidStateException class GitAutograderBranch: @@ -14,6 +14,11 @@ class GitAutograderBranch: 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 diff --git a/src/git_autograder/commit.py b/src/git_autograder/commit.py index 95a2fc9..fd55fef 100644 --- a/src/git_autograder/commit.py +++ b/src/git_autograder/commit.py @@ -1,4 +1,4 @@ -from typing import Union +from typing import Any, Union from git import Commit, Stats @@ -7,6 +7,11 @@ 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 diff --git a/src/git_autograder/remote.py b/src/git_autograder/remote.py index 1e752aa..3e6a7a4 100644 --- a/src/git_autograder/remote.py +++ b/src/git_autograder/remote.py @@ -1,4 +1,5 @@ -from typing import List +from typing import Any, List + from git import Remote @@ -6,6 +7,11 @@ 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. From 67ba91e56fe770630dab8fc3eb61f4541ad1a6f7 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 19:09:49 +0800 Subject: [PATCH 75/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 10cf997..cb20563 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.9.2b1" +version = "3.9.3b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 97ed73f41edb076f6580bb4e9a59347a534034cf Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 19:25:28 +0800 Subject: [PATCH 76/99] Create test loader to reduce duplication of setup unit test --- src/git_autograder/test_utils.py | 63 +++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 2 deletions(-) diff --git a/src/git_autograder/test_utils.py b/src/git_autograder/test_utils.py index 9b802bd..9aa3393 100644 --- a/src/git_autograder/test_utils.py +++ b/src/git_autograder/test_utils.py @@ -32,6 +32,67 @@ 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, @@ -56,7 +117,6 @@ def setup_autograder( GitAutograderInvalidStateException, GitAutograderWrongAnswerException, ) as e: - print(e) output = GitAutograderOutput( exercise_name=exercise_name, started_at=started_at, @@ -70,7 +130,6 @@ def setup_autograder( ), ) except Exception as e: - print(e) # Unexpected exception output = GitAutograderOutput( exercise_name=exercise_name, From 7dd2dc86a51cbdddadb27f7283a6a87e753df32c Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 19:25:38 +0800 Subject: [PATCH 77/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index cb20563..205bde5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.9.3b1" +version = "3.9.4b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 3e8ee84c57e50d77c7ccaf2ffe247769fc8fca44 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 19:28:26 +0800 Subject: [PATCH 78/99] Add GitAutograderTestLoader in __init__ --- src/git_autograder/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index 50a7ffa..6863efb 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -3,6 +3,7 @@ "setup_autograder", "set_env", "assert_output", + "GitAutograderTestLoader", "GitAutograderRepo", "GitAutograderStatus", "GitAutograderOutput", @@ -18,4 +19,9 @@ from .branch import GitAutograderBranch from .remote import GitAutograderRemote from .decorators import autograder -from .test_utils import setup_autograder, set_env, assert_output +from .test_utils import ( + setup_autograder, + set_env, + assert_output, + GitAutograderTestLoader, +) From fedc98173127703a489e605b03d77795983a3fe3 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Thu, 10 Apr 2025 19:28:34 +0800 Subject: [PATCH 79/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 205bde5..93d40ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.9.4b1" +version = "3.9.5b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From dd6ce09f7071882d264c22c200326a1937cd0b54 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 08:09:51 +0800 Subject: [PATCH 80/99] Add has_edited_line --- src/git_autograder/diff/diff.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/git_autograder/diff/diff.py b/src/git_autograder/diff/diff.py index 5f7ea61..0a5576c 100644 --- a/src/git_autograder/diff/diff.py +++ b/src/git_autograder/diff/diff.py @@ -34,3 +34,13 @@ def has_added_line(self) -> bool: 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 From 25426a6f4ef18aec56bcdbd507012555ff10cea2 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 08:09:57 +0800 Subject: [PATCH 81/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 93d40ee..14249fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.9.5b1" +version = "3.9.6b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From b0625b2bd0755cf8021c1d9d7b7b201d87828eb3 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 10:47:29 +0800 Subject: [PATCH 82/99] Use difflib-parser v2 --- src/git_autograder/diff/diff.py | 4 ++-- src/git_autograder/diff/diff_helper.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/git_autograder/diff/diff.py b/src/git_autograder/diff/diff.py index 0a5576c..b44d7f8 100644 --- a/src/git_autograder/diff/diff.py +++ b/src/git_autograder/diff/diff.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from typing import Optional -from difflib_parser.difflib_parser import DiffCode, DiffParser +from difflib_parser import DiffCode, DifflibParser from git.diff import Diff, Lit_change_type @@ -13,7 +13,7 @@ class GitAutograderDiff: edited_file_path: Optional[str] original_file: Optional[str] edited_file: Optional[str] - diff_parser: Optional[DiffParser] + diff_parser: Optional[DifflibParser] def has_deleted_line(self) -> bool: if self.diff_parser is None: diff --git a/src/git_autograder/diff/diff_helper.py b/src/git_autograder/diff/diff_helper.py index 3dc9fee..b1056db 100644 --- a/src/git_autograder/diff/diff_helper.py +++ b/src/git_autograder/diff/diff_helper.py @@ -1,6 +1,6 @@ from typing import Iterator, List, Optional, Tuple, Union -from difflib_parser.difflib_parser import DiffParser +from difflib_parser import DifflibParser from git import Commit, Diff, DiffIndex from git.diff import Lit_change_type @@ -68,7 +68,7 @@ def iter_changes(self, change_type: Lit_change_type) -> Iterator[GitAutograderDi ) diff_parser = ( - DiffParser(original_file.split("\n"), edited_file.split("\n")) + DifflibParser(original_file.split("\n"), edited_file.split("\n")) if original_file is not None and edited_file is not None else None ) From 543c16dcf3e700da546c1408c741f1cdec4bcac5 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 10:48:27 +0800 Subject: [PATCH 83/99] Remove src. from test --- tests/test_answers_parser.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_answers_parser.py b/tests/test_answers_parser.py index 81d1f22..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.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, ) From 2bd253ed6dc4d2679348669e74b7fca8aacb5426 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 10:48:35 +0800 Subject: [PATCH 84/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 14249fe..9db5807 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.9.6b1" +version = "3.9.7b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 7c63a3537c439d31cbebaf18fe96f9ae4c513f57 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 14:26:06 +0800 Subject: [PATCH 85/99] Add FileHelper --- src/git_autograder/helpers/__init__.py | 3 ++- src/git_autograder/helpers/file_helper.py | 30 +++++++++++++++++++++++ src/git_autograder/repo.py | 3 ++- 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 src/git_autograder/helpers/file_helper.py diff --git a/src/git_autograder/helpers/__init__.py b/src/git_autograder/helpers/__init__.py index ea572ad..938eb72 100644 --- a/src/git_autograder/helpers/__init__.py +++ b/src/git_autograder/helpers/__init__.py @@ -1,5 +1,6 @@ -__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper"] +__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/file_helper.py b/src/git_autograder/helpers/file_helper.py new file mode 100644 index 0000000..dbf8d8c --- /dev/null +++ b/src/git_autograder/helpers/file_helper.py @@ -0,0 +1,30 @@ +import os +from contextlib import contextmanager +from io import TextIOWrapper, _WrappedBuffer +from typing import Iterator, Optional, Union + +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[TextIOWrapper[_WrappedBuffer]]]: + 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[TextIOWrapper[_WrappedBuffer]]: + 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/repo.py b/src/git_autograder/repo.py index ba49b3b..5a77e9a 100644 --- a/src/git_autograder/repo.py +++ b/src/git_autograder/repo.py @@ -1,5 +1,4 @@ import os -from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import List, Optional @@ -15,6 +14,7 @@ ) 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 @@ -45,6 +45,7 @@ def __init__( 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 From e02b6481ab6654eead4069bc7203641dd5ee23a5 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 14:26:15 +0800 Subject: [PATCH 86/99] Bump minor version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9db5807..d45bfe5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.9.7b1" +version = "3.10.0b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 2ae2d8c203adc0d0491af481af625206a9ce6a58 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 15:02:53 +0800 Subject: [PATCH 87/99] Fix type hint for TextIO --- src/git_autograder/helpers/file_helper.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/git_autograder/helpers/file_helper.py b/src/git_autograder/helpers/file_helper.py index dbf8d8c..c38e3c5 100644 --- a/src/git_autograder/helpers/file_helper.py +++ b/src/git_autograder/helpers/file_helper.py @@ -1,7 +1,6 @@ import os from contextlib import contextmanager -from io import TextIOWrapper, _WrappedBuffer -from typing import Iterator, Optional, Union +from typing import Iterator, Optional, Union, TextIO from git import Repo @@ -13,7 +12,7 @@ def __init__(self, repo: Repo) -> None: @contextmanager def file_or_none( self, path: Union[str, os.PathLike[str]] - ) -> Iterator[Optional[TextIOWrapper[_WrappedBuffer]]]: + ) -> Iterator[Optional[TextIO]]: file_path = os.path.join(self.repo.working_dir, path) if not os.path.isfile(file_path): yield None @@ -22,9 +21,7 @@ def file_or_none( yield file @contextmanager - def file( - self, path: Union[str, os.PathLike[str]] - ) -> Iterator[TextIOWrapper[_WrappedBuffer]]: + 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 From b71381acfa4f2eee39db31a0d283e55483786efd Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 15:03:00 +0800 Subject: [PATCH 88/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d45bfe5..b71bf16 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.10.0b1" +version = "3.10.1b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From e8192b2d190f0b4d56a13a0524111c8232442d38 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 15:15:14 +0800 Subject: [PATCH 89/99] Add checkout method to branch --- src/git_autograder/branch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/git_autograder/branch.py b/src/git_autograder/branch.py index e41fe0d..788d20c 100644 --- a/src/git_autograder/branch.py +++ b/src/git_autograder/branch.py @@ -106,3 +106,6 @@ def has_added_file(self, file_path: str) -> bool: if diff.edited_file_path == file_path: return True return False + + def checkout(self) -> None: + self.branch.checkout() From 2a8ea74ffdcd3bb9493b440c968a4931abb82726 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 15:16:48 +0800 Subject: [PATCH 90/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index b71bf16..10106e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.10.1b1" +version = "3.10.2b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 232e227256f24c24f92906703b7832b3ac3642c9 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 15:39:53 +0800 Subject: [PATCH 91/99] Add helper methods and property to GitAutograderCommit --- src/git_autograder/commit.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/git_autograder/commit.py b/src/git_autograder/commit.py index fd55fef..0f5cf7f 100644 --- a/src/git_autograder/commit.py +++ b/src/git_autograder/commit.py @@ -1,4 +1,4 @@ -from typing import Any, Union +from typing import Any, List, Sequence, Union from git import Commit, Stats @@ -20,6 +20,10 @@ def hexsha(self) -> str: def stats(self) -> Stats: return self.commit.stats + @property + def parents(self) -> Sequence["GitAutograderCommit"]: + return [GitAutograderCommit(parent) for parent in self.commit.parents] + def is_child(self, parent: Union[Commit, "GitAutograderCommit"]) -> bool: def _is_child(child: Commit, parent: Commit) -> bool: if child == parent: @@ -34,3 +38,10 @@ def _is_child(child: Commit, parent: Commit) -> bool: return _is_child( self.commit, parent if isinstance(parent, Commit) else parent.commit ) + + 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")] From bc498265d93e8ce227a47274e2a63ab8f51822ea Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 15:40:00 +0800 Subject: [PATCH 92/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 10106e8..fba4274 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.10.2b1" +version = "3.10.3b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 1a96a8a031e4c59977e21b0b37f85c2b77f93a11 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 16:26:30 +0800 Subject: [PATCH 93/99] Add helper function to get commit file change type --- src/git_autograder/commit.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/git_autograder/commit.py b/src/git_autograder/commit.py index 0f5cf7f..575a290 100644 --- a/src/git_autograder/commit.py +++ b/src/git_autograder/commit.py @@ -1,4 +1,4 @@ -from typing import Any, List, Sequence, Union +from typing import Any, List, Optional, Sequence, Union from git import Commit, Stats @@ -45,3 +45,8 @@ def branches(self) -> List[str]: """ containing_branches = self.commit.repo.git.branch("--contains", self.hexsha) return [line[2:] for line in containing_branches.split("\n")] + + 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"] From 328927bfda999bbf9c279917721b5bdcf3e6e223 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 16:26:39 +0800 Subject: [PATCH 94/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fba4274..9841ad7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.10.3b1" +version = "3.10.4b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From eac74fc3c0942c85b536606d055c9e1091e10d44 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 16:54:55 +0800 Subject: [PATCH 95/99] Convert branches to property --- src/git_autograder/commit.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/git_autograder/commit.py b/src/git_autograder/commit.py index 575a290..b4ea4f6 100644 --- a/src/git_autograder/commit.py +++ b/src/git_autograder/commit.py @@ -24,6 +24,14 @@ def stats(self) -> Stats: 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: @@ -39,13 +47,6 @@ def _is_child(child: Commit, parent: Commit) -> bool: self.commit, parent if isinstance(parent, Commit) else parent.commit ) - 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 file_change_type(self, file_name: str) -> Optional[str]: if file_name not in self.stats.files: return None From 0393a4fad5ee5cf622aeb04588a6157903925b8f Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 16:55:05 +0800 Subject: [PATCH 96/99] Bump patch version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 9841ad7..51a79ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.10.4b1" +version = "3.10.5b1" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 436de441709ed585f3a260e11dd19377a2c0e859 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 16:56:28 +0800 Subject: [PATCH 97/99] Release v3 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 51a79ea..7271e7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "git-autograder" -version = "3.10.5b1" +version = "3.0.0" authors = [{ name = "Jiahao, Woo", email = "woojiahao1234@gmail.com" }] description = "Library for autograding Git repositories" readme = "README.md" From 25a28c793c8e01a0435951a81af69009c809f282 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 17:30:13 +0800 Subject: [PATCH 98/99] Update documentation --- README.md | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) 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 From c47c8e93a8f8f47715bafffd40a2fdfbd3654e24 Mon Sep 17 00:00:00 2001 From: "Jiahao, Woo" Date: Fri, 11 Apr 2025 17:34:10 +0800 Subject: [PATCH 99/99] Add CI to run unit tests --- .github/workflows/ci.yml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .github/workflows/ci.yml 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