diff --git a/src/git_autograder/__init__.py b/src/git_autograder/__init__.py index 41ec0b0..bf0c7c7 100644 --- a/src/git_autograder/__init__.py +++ b/src/git_autograder/__init__.py @@ -10,6 +10,7 @@ "GitAutograderRemote", "GitAutograderCommit", "GitAutograderExercise", + "GitAutograderTag" ] from .branch import GitAutograderBranch @@ -25,3 +26,4 @@ from .repo.repo import GitAutograderRepo from .repo.repo_base import GitAutograderRepoBase from .status import GitAutograderStatus +from .tag import GitAutograderTag diff --git a/src/git_autograder/branch.py b/src/git_autograder/branch.py index 8a4e3c7..55a4262 100644 --- a/src/git_autograder/branch.py +++ b/src/git_autograder/branch.py @@ -141,5 +141,17 @@ def has_added_file(self, file_path: str) -> bool: return True return False + def has_at_least_commits(self, n: int, *, user_only: bool = True) -> bool: + """ + Return True if the branch has at least n commits. + + If user_only=True, count only user commits. + """ + if n < 0: + raise ValueError("n must be >= 0") + + commits = self.user_commits if user_only else self.commits + return len(commits) >= n + def checkout(self) -> None: self.branch.checkout() diff --git a/src/git_autograder/commit.py b/src/git_autograder/commit.py index d67cf71..dfbc941 100644 --- a/src/git_autograder/commit.py +++ b/src/git_autograder/commit.py @@ -34,6 +34,13 @@ 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")] + @property + def message(self) -> str: + msg = self.commit.message + if isinstance(msg, bytes): + return msg.decode("utf-8", errors="replace") + return msg + def checkout(self) -> None: self.commit.repo.git.checkout(self.commit) diff --git a/src/git_autograder/exercise.py b/src/git_autograder/exercise.py index 58a3d4c..a6b40ec 100644 --- a/src/git_autograder/exercise.py +++ b/src/git_autograder/exercise.py @@ -6,7 +6,7 @@ from typing import Any, Dict, List, Literal, Optional import pytz -from git import InvalidGitRepositoryError +from git import InvalidGitRepositoryError, Repo from git_autograder.answers.answers import GitAutograderAnswers from git_autograder.answers.answers_parser import GitAutograderAnswersParser @@ -78,6 +78,16 @@ def __init__( self.__answers_parser: Optional[GitAutograderAnswersParser] = None self.__answers: Optional[GitAutograderAnswers] = None + @property + def git_repo(self) -> Repo: + ignored_repo_types = {"ignore", "local-ignore"} + if self.config.exercise_repo.repo_type in ignored_repo_types: + raise AttributeError( + "Cannot access attribute git_repo on GitAutograderExercise. " + "Check that your repo_type is not 'ignore' or 'local-ignore'." + ) + return self.repo.repo + @property def answers(self) -> GitAutograderAnswers: """Parses a QnA file (answers.txt). Verifies that the file exists.""" diff --git a/src/git_autograder/helpers/__init__.py b/src/git_autograder/helpers/__init__.py index 938eb72..a2e325d 100644 --- a/src/git_autograder/helpers/__init__.py +++ b/src/git_autograder/helpers/__init__.py @@ -1,6 +1,7 @@ -__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper", "FileHelper"] +__all__ = ["BranchHelper", "CommitHelper", "RemoteHelper", "FileHelper", "TagHelper"] from .branch_helper import BranchHelper from .commit_helper import CommitHelper from .remote_helper import RemoteHelper from .file_helper import FileHelper +from .tag_helper import TagHelper diff --git a/src/git_autograder/helpers/commit_helper.py b/src/git_autograder/helpers/commit_helper.py index 183608a..e952f45 100644 --- a/src/git_autograder/helpers/commit_helper.py +++ b/src/git_autograder/helpers/commit_helper.py @@ -22,3 +22,12 @@ def commit_or_none( return GitAutograderCommit(c) except Exception: return None + + def commit_from_message( + self, commits: list[GitAutograderCommit], message: str + ) -> Optional[GitAutograderCommit]: + target = message.strip() + for commit in commits: + if commit.message.strip() == target: + return commit + return None diff --git a/src/git_autograder/helpers/file_helper.py b/src/git_autograder/helpers/file_helper.py index b10595d..c6efd39 100644 --- a/src/git_autograder/helpers/file_helper.py +++ b/src/git_autograder/helpers/file_helper.py @@ -17,14 +17,65 @@ def file_or_none( if not os.path.isfile(file_path): yield None else: - with open(file_path, "r") as file: + with open(file_path, "r", encoding="utf-8") as file: yield file @contextmanager def file(self, path: Union[str, os.PathLike[str]]) -> Iterator[TextIO]: file_path = os.path.join(self.repo.working_dir, path) - with open(file_path, "r") as file: + with open(file_path, "r", encoding="utf-8") as file: yield file def untracked_files(self) -> List[str]: return self.repo.untracked_files + + def is_content_equal(self, path: Union[str, os.PathLike[str]], expected: str) -> bool: + with self.file_or_none(path) as input_file: + if input_file is None: + return False + + input_lines = [ + line.strip() for line in input_file.readlines() if line.strip() != "" + ] + + expected_lines = [ + line.strip() for line in expected.splitlines() if line.strip() != "" + ] + + return input_lines == expected_lines + + def are_files_equal( + self, + given: Union[str, os.PathLike[str]], + expected: Union[str, os.PathLike[str]], + *, + exact_match: bool = False, + ) -> bool: + """ + Compare two repo-relative files. + Returns False if either file is missing. + + - exact_match=False: compare normalized non-empty stripped lines. + - exact_match=True: compare full file contents exactly. + """ + with self.file_or_none(given) as given_file: + if given_file is None: + return False + given_contents = given_file.read() + + with self.file_or_none(expected) as expected_file: + if expected_file is None: + return False + expected_contents = expected_file.read() + + if exact_match: + return given_contents == expected_contents + + given_lines = [ + line.strip() for line in given_contents.splitlines() if line.strip() != "" + ] + expected_lines = [ + line.strip() for line in expected_contents.splitlines() if line.strip() != "" + ] + + return given_lines == expected_lines diff --git a/src/git_autograder/helpers/tag_helper.py b/src/git_autograder/helpers/tag_helper.py new file mode 100644 index 0000000..e4c502e --- /dev/null +++ b/src/git_autograder/helpers/tag_helper.py @@ -0,0 +1,79 @@ +from typing import List, Optional + +from git import Repo +from git.exc import GitCommandError + +from git_autograder.exception import GitAutograderInvalidStateException +from git_autograder.tag import GitAutograderTag + + +class TagHelper: + MISSING_TAG = "Tag {tag} is missing." + MISSING_REMOTE = "Remote {remote} is missing." + CANNOT_QUERY_REMOTE_TAGS = "Unable to query remote tags for '{remote}'." + + @staticmethod + def _parse_remote_tag_names(raw: str) -> list[str]: + names: list[str] = [] + + for line in raw.splitlines(): + parts = line.split() + if len(parts) != 2: + continue + + ref = parts[1] + if not ref.startswith("refs/tags/"): + continue + + name = ref[len("refs/tags/") :] + if name.endswith("^{}"): + name = name[:-3] + + if name not in names: + names.append(name) + + return names + + def __init__(self, repo: Repo) -> None: + self.repo = repo + + def tag_or_none(self, tag_name: str) -> Optional[GitAutograderTag]: + for tag_ref in self.repo.tags: + if str(tag_ref) == tag_name: + return GitAutograderTag(tag_ref) + return None + + def tag(self, tag_name: str) -> GitAutograderTag: + t = self.tag_or_none(tag_name) + if t is None: + raise GitAutograderInvalidStateException( + self.MISSING_TAG.format(tag=tag_name) + ) + return t + + def has_tag(self, tag_name: str) -> bool: + return self.tag_or_none(tag_name) is not None + + def remote_tag_names_or_none(self, remote: str = "origin") -> Optional[List[str]]: + if not any(r.name == remote for r in self.repo.remotes): + return None + + try: + raw = self.repo.git.ls_remote("--tags", remote) + except GitCommandError: + return None + + return self._parse_remote_tag_names(raw) + + def remote_tag_names(self, remote: str = "origin") -> List[str]: + if not any(r.name == remote for r in self.repo.remotes): + raise GitAutograderInvalidStateException( + self.MISSING_REMOTE.format(remote=remote) + ) + + names = self.remote_tag_names_or_none(remote) + if names is None: + raise GitAutograderInvalidStateException( + self.CANNOT_QUERY_REMOTE_TAGS.format(remote=remote) + ) + return names diff --git a/src/git_autograder/repo/null_repo.py b/src/git_autograder/repo/null_repo.py index 553e529..f70557f 100644 --- a/src/git_autograder/repo/null_repo.py +++ b/src/git_autograder/repo/null_repo.py @@ -4,6 +4,7 @@ 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.helpers.tag_helper import TagHelper from git_autograder.repo.repo_base import GitAutograderRepoBase @@ -37,6 +38,12 @@ def files(self) -> FileHelper: raise AttributeError( "Cannot access attribute files on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." ) + + @property + def tags(self) -> TagHelper: + raise AttributeError( + "Cannot access attribute tags on NullGitAutograderRepo. Check that your repo_type is not 'ignore'." + ) def __getattr__(self, name: str) -> None: raise AttributeError( diff --git a/src/git_autograder/repo/repo.py b/src/git_autograder/repo/repo.py index 9dbd36d..7e58724 100644 --- a/src/git_autograder/repo/repo.py +++ b/src/git_autograder/repo/repo.py @@ -6,6 +6,7 @@ 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.helpers.tag_helper import TagHelper from git_autograder.repo.repo_base import GitAutograderRepoBase @@ -24,6 +25,7 @@ def __init__( self._commits: CommitHelper = CommitHelper(self._repo) self._remotes: RemoteHelper = RemoteHelper(self._repo) self._files: FileHelper = FileHelper(self._repo) + self._tags: TagHelper = TagHelper(self._repo) @property def repo(self) -> Repo: @@ -44,3 +46,7 @@ def remotes(self) -> RemoteHelper: @property def files(self) -> FileHelper: return self._files + + @property + def tags(self) -> TagHelper: + return self._tags diff --git a/src/git_autograder/repo/repo_base.py b/src/git_autograder/repo/repo_base.py index b0f2ef3..9302f87 100644 --- a/src/git_autograder/repo/repo_base.py +++ b/src/git_autograder/repo/repo_base.py @@ -6,6 +6,7 @@ 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.helpers.tag_helper import TagHelper class GitAutograderRepoBase(ABC): @@ -28,3 +29,7 @@ def remotes(self) -> RemoteHelper: ... @property @abstractmethod def files(self) -> FileHelper: ... + + @property + @abstractmethod + def tags(self) -> TagHelper: ... diff --git a/src/git_autograder/tag.py b/src/git_autograder/tag.py new file mode 100644 index 0000000..e5a8577 --- /dev/null +++ b/src/git_autograder/tag.py @@ -0,0 +1,45 @@ +from typing import Any, Optional + +from git.refs.tag import TagReference + +from git_autograder.commit import GitAutograderCommit + + +class GitAutograderTag: + def __init__(self, tag_ref: TagReference) -> None: + self.tag_ref = tag_ref + + def __eq__(self, value: Any) -> bool: + return isinstance(value, GitAutograderTag) and value.tag_ref == self.tag_ref + + @property + def name(self) -> str: + return str(self.tag_ref) + + @property + def commit(self) -> GitAutograderCommit: + return GitAutograderCommit(self.tag_ref.commit) + + @property + def is_annotated(self) -> bool: + return self.tag_ref.tag is not None + + @property + def is_lightweight(self) -> bool: + return self.tag_ref.tag is None + + def message_or_none( + self, *, strip: bool = True, lower: bool = False + ) -> Optional[str]: + if self.tag_ref.tag is None: + return None + + message = self.tag_ref.tag.message + if strip: + message = message.strip() + if lower: + message = message.lower() + return message + + def points_to(self, commit: GitAutograderCommit) -> bool: + return self.commit.hexsha == commit.hexsha