Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/git_autograder/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"GitAutograderRemote",
"GitAutograderCommit",
"GitAutograderExercise",
"GitAutograderTag"
]

from .branch import GitAutograderBranch
Expand All @@ -25,3 +26,4 @@
from .repo.repo import GitAutograderRepo
from .repo.repo_base import GitAutograderRepoBase
from .status import GitAutograderStatus
from .tag import GitAutograderTag
12 changes: 12 additions & 0 deletions src/git_autograder/branch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
7 changes: 7 additions & 0 deletions src/git_autograder/commit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
12 changes: 11 additions & 1 deletion src/git_autograder/exercise.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
3 changes: 2 additions & 1 deletion src/git_autograder/helpers/__init__.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions src/git_autograder/helpers/commit_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
55 changes: 53 additions & 2 deletions src/git_autograder/helpers/file_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
79 changes: 79 additions & 0 deletions src/git_autograder/helpers/tag_helper.py
Original file line number Diff line number Diff line change
@@ -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)
Comment on lines +32 to +33
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the ordering of the tag important in this case, as using set will be more efficient?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally I find using of set is unnecessary, just like you pointed out in the previous review. For a single exercise, it is unlikely that we have large number of tags such that we will need to use a set to keep track of the tags. It would be good to keep the ordering in some cases.


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
7 changes: 7 additions & 0 deletions src/git_autograder/repo/null_repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions src/git_autograder/repo/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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:
Expand All @@ -44,3 +46,7 @@ def remotes(self) -> RemoteHelper:
@property
def files(self) -> FileHelper:
return self._files

@property
def tags(self) -> TagHelper:
return self._tags
5 changes: 5 additions & 0 deletions src/git_autograder/repo/repo_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -28,3 +29,7 @@ def remotes(self) -> RemoteHelper: ...
@property
@abstractmethod
def files(self) -> FileHelper: ...

@property
@abstractmethod
def tags(self) -> TagHelper: ...
45 changes: 45 additions & 0 deletions src/git_autograder/tag.py
Original file line number Diff line number Diff line change
@@ -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
Loading