Source code for model_repository.git_backend

"""Small, replaceable Git object-store adapter for simulation models."""

from abc import ABC, abstractmethod
from pathlib import Path, PurePosixPath
from threading import Lock

_OWNER_VALIDATION_LOCK = Lock()


def _open_repository_without_owner_validation(pygit2, repository_path):
    """Open the explicitly configured model repository without owner validation."""
    option = pygit2.enums.Option
    with _OWNER_VALIDATION_LOCK:
        owner_validation = pygit2.option(option.GET_OWNER_VALIDATION)
        try:
            pygit2.option(option.SET_OWNER_VALIDATION, False)
            return pygit2.Repository(str(repository_path))
        finally:
            pygit2.option(option.SET_OWNER_VALIDATION, owner_validation)


[docs] class GitModelSourceDependencyError(RuntimeError): """Raised when the Git model source is selected without its runtime dependency."""
[docs] class GitObjectStore(ABC): """Library-neutral interface used by ``GitStorage``."""
[docs] @abstractmethod def resolve_revision(self, revision): """Resolve a ref or commit to a full commit ID."""
[docs] @abstractmethod def iter_files(self, commit, prefix): """Return file paths below ``prefix`` for ``commit``."""
[docs] @abstractmethod def read_blob(self, commit, path): """Return one blob as bytes."""
[docs] @abstractmethod def open_blob(self, commit, path): """Return a binary stream for one blob."""
[docs] class Pygit2ObjectStore(GitObjectStore): """Read objects from a local normal, bare, or mirror repository.""" def __init__(self, repository_path): """Open a local Git repository without creating a working tree.""" try: import pygit2 # pylint: disable=import-outside-toplevel except ImportError as exc: raise GitModelSourceDependencyError( "The Git simulation-model source requires pygit2; " "install simtools with its standard dependencies." ) from exc self.repository_path = Path(repository_path).expanduser().resolve() if not self.repository_path.exists(): raise FileNotFoundError(f"Git model repository does not exist: {self.repository_path}") try: self._repository = _open_repository_without_owner_validation( pygit2, self.repository_path ) except (KeyError, OSError, ValueError, pygit2.GitError) as exc: raise ValueError( f"Not a readable Git model repository: {self.repository_path}" ) from exc self._pygit2 = pygit2
[docs] def resolve_revision(self, revision): """Resolve a tag, ref, or SHA to a full commit SHA.""" try: obj = self._repository.revparse_single(str(revision)) commit = obj.peel(self._pygit2.Commit) except (KeyError, ValueError, TypeError) as exc: raise ValueError( f"Git revision {revision!r} not found or does not identify a commit " f"in {self.repository_path}" ) from exc return str(commit.id)
def _commit_tree(self, commit): """Return the tree for a resolved commit.""" try: return self._repository[str(commit)].tree except (KeyError, ValueError, TypeError) as exc: raise ValueError(f"Git commit {commit!r} not found in {self.repository_path}") from exc def _tree_object(self, commit, path): """Return a tree object by its POSIX repository path.""" obj = self._commit_tree(commit) for part in PurePosixPath(path).parts: try: obj = self._dereference_tree_entry(obj[part]) except (KeyError, ValueError, TypeError) as exc: raise FileNotFoundError( f"Git path {path!r} not found at commit {commit} in {self.repository_path}" ) from exc return obj def _dereference_tree_entry(self, entry): """Return the Git object represented by a pygit2 tree entry.""" if hasattr(entry, "id"): return self._repository[entry.id] return entry
[docs] def iter_files(self, commit, prefix): """Return sorted regular-file paths below a tree prefix.""" prefix = PurePosixPath(prefix) tree = self._commit_tree(commit) for part in prefix.parts: try: tree = self._dereference_tree_entry(tree[part]) except (KeyError, ValueError, TypeError) as exc: raise FileNotFoundError( f"Git path {prefix!s} not found at commit {commit} in {self.repository_path}" ) from exc if tree.type_str != "tree": raise ValueError(f"Git path is not a directory: {prefix}") paths = [] self._append_files(tree, prefix, paths) return sorted(paths)
def _append_files(self, tree, prefix, paths): """Recursively collect paths from a tree.""" for obj in tree: path = prefix / obj.name if obj.type_str == "tree": self._append_files(self._dereference_tree_entry(obj), path, paths) elif obj.type_str == "blob": paths.append(path.as_posix()) def _blob(self, commit, path): """Return a blob object and provide a source-specific error.""" obj = self._tree_object(commit, path) if obj.type_str != "blob": raise ValueError(f"Git path is not a file: {path}") return obj
[docs] def read_blob(self, commit, path): """Read a blob into memory.""" return self._blob(commit, path).data
[docs] def open_blob(self, commit, path): """Open a blob as a binary stream without materializing it in memory.""" return self._pygit2.BlobIO(self._blob(commit, path))