Coverage for /usr/local/lib/python3.10/site-packages/opal_common-0.0.0-py3.10.egg/opal_common/git_utils/commit_viewer.py: 71%
91 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 11:54 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 11:54 +0000
1from pathlib import Path
2from typing import IO, Callable, Generator, List, Optional, Set
4from git import Repo
5from git.objects import Blob, Commit, IndexObject, Tree
6from opal_common.paths import PathUtils
9class VersionedNode:
10 """A *versioned* file or a directory in a git repo.
12 VersionedNode is a base class for `VersionedFile` and
13 `VersionedDirectory`.
14 """
16 def __init__(self, node: IndexObject, commit: Commit):
17 self._node = node
18 self._commit = commit
19 self._repo: Repo = commit.repo
21 @property
22 def repo(self) -> Repo:
23 """The repo containing the versioned node."""
24 return self._repo
26 @property
27 def commit(self) -> Commit:
28 """The commit in which the node (blob, tree) is located."""
29 return self._commit
31 @property
32 def version(self) -> str:
33 """The hash (hex sha) of the node's parent commit."""
34 return self._commit.hexsha
36 @property
37 def path(self) -> Path:
38 """The relative path to the node (either file path or directory path),
39 relative to the repo root."""
40 return Path(self._node.path)
43class VersionedFile(VersionedNode):
44 """Each instance of this class represents *one version* of a file (blob) in
45 a git repo (the version of the file for a specific git commit)."""
47 def __init__(self, blob: Blob, commit: Commit):
48 super().__init__(blob, commit)
49 self._blob: Blob = blob
51 @property
52 def blob(self) -> Blob:
53 """The blob containing metadata for the file version."""
54 return self._blob
56 @property
57 def stream(self) -> IO:
58 """An io stream to the version of the file represented by that
59 instance.
61 reading that stream will return the contents of the file for
62 that specific version (commit).
63 """
64 return self.blob.data_stream
66 def read_bytes(self) -> bytes:
67 """Returns the contents of the file as a byte array (without
68 encoding)."""
69 return self.stream.read()
71 def read(self, encoding="utf-8") -> str:
72 """Returns the contents of the file as a string, decoded according to
73 the input `encoding`.
75 (by default, git usually encodes source files as utf-8).
76 """
77 return self.read_bytes().decode(encoding=encoding)
80class VersionedDirectory(VersionedNode):
81 """Each instance of this class represents *one version* of a directory (git
82 tree) in a git repo (the version of the directory for a specific git
83 commit)."""
85 def __init__(self, directory: Tree, commit: Commit):
86 super().__init__(directory, commit)
87 self._dir: Tree = directory
89 @property
90 def dir(self) -> Tree:
91 """The git tree representing the metadata for that version of the
92 directory.
94 i.e: one can get child directories (trees) and files (blobs) for
95 the instance's version.
96 """
97 return self._dir
100NodeFilter = Callable[[VersionedNode], bool]
101FileFilter = Callable[[VersionedFile], bool]
102DirectoryFilter = Callable[[VersionedDirectory], bool]
105def has_extension(f: VersionedFile, extensions: Optional[List[str]] = None) -> bool:
106 """A filter on versioned files, filters only files with specific types
107 (file extensions)."""
108 if extensions is None:
109 return True # no filter
110 else:
111 return f.path.suffix in extensions
114def find_ignore_match(
115 maybe_path: Path, bundle_ignore: Optional[List[str]]
116) -> Optional[str]:
117 """Determines the ignored glob path, if any, which matches the given file's
118 path.
120 Returns the matched glob path rather than a binary decision of
121 whether there is a match to enable better logging in the case of
122 matched paths in manifests.
123 """
124 if bundle_ignore is not None:
125 return PathUtils.glob_style_match_path_to_list(
126 Path(maybe_path).as_posix(), bundle_ignore
127 )
128 return None
131def is_under_directories(f: VersionedFile, directories: Set[Path]) -> bool:
132 """A filter on versioned files, filters only files under certain
133 directories in the repo."""
134 return PathUtils.is_child_of_directories(f.path, directories)
137class CommitViewer:
138 """This class allows us to view the repository files and directories from
139 the perspective of a specific git commit (i.e: version).
141 i.e: if in the latest commit we removed a file called `a.txt`, we
142 will see it while initializing CommitViewer with commit=HEAD~1, but
143 we will not see `a.txt` if we initialize the CommitViewer with
144 commit=HEAD.
146 The viewer also allows us to filter out certain paths of the commit
147 tree.
148 """
150 def __init__(self, commit: Commit):
151 """[summary]
153 Args:
154 commit (Commit): the commit that defines the perspective (or lens)
155 through which we look at the repo filesystem. i.e: the commit
156 that defines the "checkout".
157 """
158 self._repo: Repo = commit.repo
159 self._commit = commit
160 self._root = commit.tree
162 def __enter__(self):
163 return self
165 def __exit__(self, exc_type, exc, tb):
166 pass
168 def nodes(
169 self, predicate: Optional[NodeFilter] = None
170 ) -> Generator[VersionedNode, None, None]:
171 """A generator yielding all the nodes (files and directories) found in
172 the repository for the current commit, after applying the filter.
174 Args:
175 predicate (Optional[NodeFilter]): an optional predicate to filter only specific nodes.
177 Yields:
178 the next node found (only for nodes passing the filter).
179 """
180 nodes_generator = self._nodes_in_tree(self._root)
181 if predicate is None: 181 ↛ 182line 181 didn't jump to line 182 because the condition on line 181 was never true
182 return nodes_generator
183 else:
184 return filter(predicate, nodes_generator)
186 def files(
187 self, predicate: Optional[FileFilter] = None
188 ) -> Generator[VersionedFile, None, None]:
189 """A generator yielding all the files found in the repository for the
190 current commit, after applying the filter.
192 Args:
193 filter (Optional[FileFilter]): an optional predicate to filter only specific files.
195 Yields:
196 the next file found (only for files passing the filter).
197 """
198 return (
199 node for node in self.nodes(predicate) if isinstance(node, VersionedFile)
200 )
202 def directories(
203 self, predicate: Optional[DirectoryFilter] = None
204 ) -> Generator[VersionedDirectory, None, None]:
205 """A generator yielding all the directories found in the repository for
206 the current commit, after applying the filter.
208 Args:
209 filter (Optional[DirectoryFilter]): an optional predicate to filter only specific directories.
211 Yields:
212 the next directory found (only for directories passing the filter).
213 """
214 return filter(
215 lambda node: isinstance(node, VersionedDirectory),
216 self.nodes(predicate),
217 )
219 def get_node(
220 self, path: Path, filterable_gen: Optional[Callable] = None
221 ) -> Optional[VersionedNode]:
222 """Returns the node in the given path, None if it doesn't exist."""
223 return next(self.nodes(lambda n: n.path == path), None)
225 def get_directory(self, path: Path) -> Optional[VersionedDirectory]:
226 """Returns the directory in the given path, None if it doesn't
227 exist."""
228 return next(self.directories(lambda n: n.path == path), None)
230 def get_file(self, path: Path) -> Optional[VersionedFile]:
231 """Returns the file in the given path, None if it doesn't exist."""
232 return next(self.files(lambda n: n.path == path), None)
234 @property
235 def paths(self) -> List[Path]:
236 """Returns all the paths in the repo for the current commit (both files
237 and directories)"""
238 return [node.path for node in self.nodes()]
240 def exists(self, path: Path) -> bool:
241 """Checks if a certain path exists in the repo in the current
242 commit."""
243 return path in self.paths
245 def _nodes_in_tree(self, root: Tree) -> Generator[VersionedNode, None, None]:
246 """A generator returning all the nodes (files and directories) under a
247 certain git Tree (a versioned directory)."""
248 # yield current directory
249 yield VersionedDirectory(root, self._commit)
250 # yield files under current directory
251 for blob in root.blobs:
252 yield VersionedFile(blob, self._commit)
253 # yield subdirectories (and their children etc) under current directory
254 for tree in root.trees: 254 ↛ 255line 254 didn't jump to line 255 because the loop on line 254 never started
255 yield from self._nodes_in_tree(tree)