batchfetch 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,329 @@
1
+ #!/usr/bin/env python
2
+ #
3
+ # Copyright (c) James Cherti
4
+ # URL: https://github.com/jamescherti/batchfetch
5
+ #
6
+ # This program is free software: you can redistribute it and/or modify it under
7
+ # the terms of the GNU General Public License as published by the Free Software
8
+ # Foundation, either version 3 of the License, or (at your option) any later
9
+ # version.
10
+ #
11
+ # This program is distributed in the hope that it will be useful, but WITHOUT
12
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
13
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
14
+ #
15
+ # You should have received a copy of the GNU General Public License along with
16
+ # this program. If not, see <https://www.gnu.org/licenses/>.
17
+ #
18
+ "Clone and update Git repositories."
19
+
20
+
21
+ import posixpath
22
+ import shutil
23
+ import subprocess
24
+ import textwrap
25
+ from pathlib import Path
26
+ from typing import List, Union
27
+
28
+ from schema import Optional
29
+
30
+ from .batchfetch_base import (BatchFetchBase, BatchFetchError,
31
+ GitBranchDoesNotExist)
32
+ from .helpers import run_simple
33
+
34
+
35
+ class BatchFetchGit(BatchFetchBase):
36
+ """Clone or update a Git repository."""
37
+
38
+ indent_spaces = " " * BatchFetchBase.indent
39
+ main_key = "git"
40
+
41
+ def __init__(self, *args, **kwargs):
42
+ super().__init__(*args, **kwargs)
43
+
44
+ # Schema
45
+ self.item_schema.update({
46
+ # Local options
47
+ BatchFetchGit.main_key: str,
48
+ Optional("branch"): str,
49
+
50
+ # Same as global options
51
+ Optional("clone_args"): [str],
52
+ Optional("git_pull"): bool,
53
+ })
54
+
55
+ self.global_options_schema.update({
56
+ # Global options
57
+ Optional("clone_args"): [str],
58
+ Optional("git_pull"): bool,
59
+ })
60
+
61
+ # Data
62
+ self.global_options_values.update({"clone_args": [],
63
+ "git_pull": True})
64
+
65
+ self.item_default_values.update({
66
+ BatchFetchGit.main_key: "",
67
+ "branch": "",
68
+ "delete": False,
69
+ })
70
+
71
+ self.git_local_dir = Path(self["path"])
72
+ self.current_branch = None
73
+ self.current_commit_ref = None
74
+
75
+ def _initialize_data(self):
76
+ super()._initialize_data()
77
+
78
+ self.values[BatchFetchGit.main_key] = \
79
+ self.values[BatchFetchGit.main_key].rstrip("/")
80
+
81
+ if "path" not in self.values:
82
+ self.values["path"] = \
83
+ posixpath.basename(
84
+ self.values[BatchFetchGit.main_key]) # type: ignore
85
+
86
+ def _git_ref(self, cwd: Union[None, Path] = None) -> str:
87
+ """Get the commit reference of HEAD."""
88
+ cmd = "git show-ref --head --verify HEAD"
89
+ try:
90
+ stdout, _ = run_simple(cmd, cwd=cwd, env=self.env)
91
+ output = stdout[0].split(" ")[0]
92
+ except IndexError:
93
+ return ""
94
+ return output
95
+
96
+ def update(self):
97
+ """Clone or update a Git repository."""
98
+ super().update()
99
+
100
+ is_clone = False
101
+
102
+ if not self.git_local_dir.exists():
103
+ is_clone = True
104
+
105
+ # Result (string)
106
+ if self["delete"]:
107
+ update_type = "DELETE"
108
+ elif is_clone:
109
+ update_type = "CLONE"
110
+ else:
111
+ update_type = "UPDATE"
112
+
113
+ self.add_output(
114
+ f"[GIT {update_type}] {self[self.main_key]}" +
115
+ (f" (branch: {self['branch']})"
116
+ if self["branch"] else "") + "\n"
117
+ )
118
+
119
+ try:
120
+ # Delete
121
+ if self["delete"]:
122
+ self._repo_delete()
123
+ # Clone
124
+ elif is_clone:
125
+ self._repo_clone()
126
+
127
+ # Update
128
+ if self.git_local_dir.is_dir():
129
+ # Pre exec
130
+ self._run_pre_exec(cwd=self.git_local_dir)
131
+
132
+ self._repo_check_remote_origin()
133
+ self._update_current_branch()
134
+ self._repo_reset()
135
+
136
+ git_merge_done = self._repo_pull()
137
+ git_branch_changed = self._repo_fix_branch()
138
+ if git_merge_done or git_branch_changed:
139
+ self._repo_update_submodules()
140
+
141
+ if self.get_changed():
142
+ self._run_post_exec(cwd=self.git_local_dir)
143
+ except BatchFetchError as err:
144
+ self.set_error(True)
145
+ self.add_output(self.indent_spaces + "[ERROR] " + str(err) + "\n")
146
+ except subprocess.CalledProcessError as err:
147
+ self.set_error(True)
148
+ self.add_output(self.indent_spaces + "[ERROR] " + str(err) + "\n")
149
+ self.add_output(self.indent_spaces +
150
+ textwrap.indent(err.stdout, " " * self.indent) +
151
+ "\n" +
152
+ textwrap.indent(err.stderr, " " * self.indent))
153
+
154
+ if not self.is_error() and not self.is_changed():
155
+ self.add_output(self.indent_spaces + "# Already up to date\n")
156
+
157
+ return self.values
158
+
159
+ def _update_current_branch(self):
160
+ try:
161
+ self.current_branch, _ = run_simple(
162
+ ["git", "symbolic-ref", "--short", "HEAD"],
163
+ env=self.env,
164
+ cwd=self.git_local_dir,
165
+ )
166
+ except subprocess.CalledProcessError:
167
+ # Not a symbolic ref
168
+ self.current_branch = None
169
+
170
+ def _repo_delete(self):
171
+ if not self.git_local_dir.exists():
172
+ self.add_output(self.indent_spaces + "# Already deleted\n")
173
+ elif not self.git_local_dir.joinpath(".git").is_dir():
174
+ self.add_output(
175
+ self.indent_spaces +
176
+ f"# Cannot be deleted because '{self.git_local_dir}' is "
177
+ "not a Git repository\n"
178
+ )
179
+ self.set_error(True)
180
+ elif self.git_local_dir.is_dir():
181
+ shutil.rmtree(str(self.git_local_dir))
182
+ self.add_output(self.indent_spaces +
183
+ f"# Deleted: '{self.git_local_dir}'")
184
+ self.set_changed(True)
185
+
186
+ def _repo_clone(self):
187
+ git_clone_args = self["clone_args"]
188
+ git_clone_args += ["--recurse-submodules"]
189
+
190
+ cmd = ["git", "clone"] + git_clone_args + \
191
+ [self[BatchFetchGit.main_key], str(self.git_local_dir)]
192
+ self._run(cmd, env=self.env)
193
+ self.set_changed(True)
194
+
195
+ def _repo_reset(self):
196
+ # Remove local changes
197
+ cmd = ["git", "reset", "--hard", "HEAD"]
198
+ self._run(cmd, cwd=str(self.git_local_dir), env=self.env)
199
+
200
+ def _repo_pull(self):
201
+ git_merge = False
202
+
203
+ # Merge
204
+ ignore_git_pull = False
205
+ if not self["git_pull"]:
206
+ ignore_git_pull = True
207
+ elif self["branch"]:
208
+ # Check if the new branch exists
209
+ try:
210
+ self._git_tags(self["branch"])
211
+ except GitBranchDoesNotExist:
212
+ ignore_git_pull = False
213
+ else:
214
+ # The branch exists
215
+ ignore_git_pull = True
216
+
217
+ # Check if the new branch is different from the current one
218
+ commit_ref = self._git_ref(cwd=self.git_local_dir)
219
+ if self.current_branch and (commit_ref == self["branch"] or
220
+ self.current_branch == self["branch"]):
221
+ ignore_git_pull = True
222
+
223
+ if ignore_git_pull:
224
+ self.add_output(self.indent_spaces +
225
+ "# git pull ignored\n")
226
+ else:
227
+ cmd = ["git", "fetch", "origin"]
228
+ self._run(cmd, cwd=str(self.git_local_dir), env=self.env)
229
+
230
+ # TODO: only merge when difference from upstream
231
+ commit_ref = self._git_ref(cwd=self.git_local_dir)
232
+ self._run(["git", "merge", "--ff-only"],
233
+ cwd=str(self.git_local_dir), env=self.env)
234
+ git_ref_after_merge = self._git_ref(cwd=self.git_local_dir)
235
+ if commit_ref != git_ref_after_merge:
236
+ git_merge = True
237
+ self.set_changed(True)
238
+ self._run(["git", "log",
239
+ '--pretty=format:"%h %ad %s [%cn]"',
240
+ "--decorate", "--date=short",
241
+ f"{commit_ref}..{git_ref_after_merge}"],
242
+ cwd=str(self.git_local_dir),
243
+ env=self.env)
244
+
245
+ return git_merge
246
+
247
+ def _git_tags(self, branch: str) -> List[str]:
248
+ stdout: List[str] = []
249
+ try:
250
+ stdout, _ = run_simple(
251
+ ["git", "rev-parse", "--verify", branch],
252
+ env=self.env,
253
+ cwd=self.git_local_dir)
254
+ except subprocess.CalledProcessError as err:
255
+ raise GitBranchDoesNotExist(
256
+ f"The branch '{branch}' does not exist.") from err
257
+
258
+ return stdout
259
+
260
+ def _repo_fix_branch(self) -> bool:
261
+ git_ref_after_merge = self._git_ref(cwd=self.git_local_dir)
262
+ branch_changed = False
263
+ if self["branch"]:
264
+ # We also need tags because sometimes, a branch
265
+ # returns a different commit reference
266
+ git_tags, _ = run_simple(
267
+ ["git", "tag", "--points-at", "HEAD"],
268
+ env=self.env,
269
+ cwd=self.git_local_dir,
270
+ )
271
+
272
+ # Also check the commit reference in case
273
+ # branch is a commit reference instead of a tag
274
+ try:
275
+ git_ref_branch = self._git_tags(self["branch"])[0]
276
+ except GitBranchDoesNotExist as err:
277
+ raise BatchFetchError(f"The branch '{self['branch']}' "
278
+ "does not exist.") from err
279
+
280
+ if git_ref_after_merge != git_ref_branch and \
281
+ self["branch"] not in git_tags:
282
+ # Update the branch
283
+ self._run(["git", "checkout"] + [self["branch"]],
284
+ cwd=str(self.git_local_dir), env=self.env)
285
+ self.add_output(self.indent_spaces + "# Branch changed to " +
286
+ self["branch"] + "\n")
287
+ self.set_changed(True)
288
+ branch_changed = True
289
+
290
+ # Read branch again
291
+ self._update_current_branch()
292
+
293
+ return branch_changed
294
+
295
+ def _repo_update_submodules(self):
296
+ # This parameter instructs Git to initiate the update
297
+ # process for submodules:
298
+ # 1. Git fetches the commits specified in the parent
299
+ # repository's configuration for each submodule.
300
+ # 2. Updates are based solely on the commit pointers stored
301
+ # within the parent repository's submodule configuration.
302
+ # 3. It does not directly consult the upstream repositories
303
+ # of the submodules.
304
+ # 4. Submodules are updated to reflect the exact commits
305
+ # referenced in the parent repository's configuration,
306
+ # potentially lagging behind the latest changes made in the
307
+ # upstream repositories.
308
+ if self.git_local_dir.joinpath(".gitmodules").is_file():
309
+ cmd = ["git", "submodule", "update", "--recursive"]
310
+ self._run(cmd, cwd=str(self.git_local_dir), env=self.env)
311
+
312
+ def _repo_check_remote_origin(self):
313
+ cmd = ["git", "config", "remote.origin.url"]
314
+ try:
315
+ stdout, _ = run_simple(cmd,
316
+ env=self.env,
317
+ cwd=self.git_local_dir)
318
+ origin_url = stdout[0]
319
+ except IndexError:
320
+ origin_url = ""
321
+
322
+ if origin_url != self[BatchFetchGit.main_key]:
323
+ self.set_error(True)
324
+ self.add_output(
325
+ self.indent_spaces +
326
+ "[ERROR] The Git remote origin URL is incorrect: "
327
+ f"'{origin_url}' (It is supposed to "
328
+ f"be '{self[self.main_key]}')\n")
329
+ raise BatchFetchError
batchfetch/helpers.py ADDED
@@ -0,0 +1,116 @@
1
+ #
2
+ # Copyright (c) James Cherti
3
+ # URL: https://github.com/jamescherti/batchfetch
4
+ #
5
+ # This program is free software: you can redistribute it and/or modify it under
6
+ # the terms of the GNU General Public License as published by the Free Software
7
+ # Foundation, either version 3 of the License, or (at your option) any later
8
+ # version.
9
+ #
10
+ # This program is distributed in the hope that it will be useful, but WITHOUT
11
+ # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
12
+ # FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License along with
15
+ # this program. If not, see <https://www.gnu.org/licenses/>.
16
+ #
17
+ "Batchfetch helper functions."
18
+
19
+ import hashlib
20
+ import os
21
+ import shlex
22
+ from subprocess import PIPE, CalledProcessError, Popen, list2cmdline
23
+ from typing import List, Tuple, Union
24
+
25
+
26
+ def md5sum(filename: os.PathLike):
27
+ """
28
+ Calculate and return the MD5 checksum of a file.
29
+
30
+ Args:
31
+ filename: The path to the file for which the MD5 checksum is calculated.
32
+
33
+ Returns:
34
+ str: The MD5 checksum of the file.
35
+
36
+ """
37
+ md5 = hashlib.md5()
38
+ with open(filename, "rb") as file:
39
+ for chunk in iter(lambda: file.read(4096), b""):
40
+ md5.update(chunk)
41
+ return md5.hexdigest()
42
+
43
+
44
+ def run_simple(cmd: Union[List[str], str],
45
+ **kwargs) -> Tuple[List[str], List[str]]:
46
+ """
47
+ Executes a command and returns stdout and stderr as separate lists of
48
+ strings.
49
+
50
+ :param cmd: Command to be executed. Can be a list or a string.
51
+ :param kwargs: Additional keyword arguments for Popen.
52
+ :return: Tuple containing two lists: stdout lines and stderr lines.
53
+ """
54
+ full_cmd = shlex.split(cmd) if isinstance(cmd, str) else cmd
55
+
56
+ with Popen(full_cmd, stdout=PIPE, stderr=PIPE, text=True,
57
+ **kwargs) as process:
58
+ stdout, stderr = process.communicate()
59
+
60
+ stdout_lines = stdout.splitlines()
61
+ stderr_lines = stderr.splitlines()
62
+
63
+ if process.returncode != 0:
64
+ raise CalledProcessError(returncode=process.returncode,
65
+ cmd=cmd,
66
+ output=stdout,
67
+ stderr=stderr)
68
+
69
+ return (stdout_lines, stderr_lines)
70
+
71
+
72
+ def run_indent_str(cmd: Union[List[str], str], **kwargs) -> str:
73
+ """
74
+ Executes a command and returns its stdout output as a single string with
75
+ preserved line breaks.
76
+
77
+ :param cmd: Command to be executed. Can be a list of strings or a single
78
+ string.
79
+ :param kwargs: Additional keyword arguments for the execution function.
80
+ :return: A string containing the stdout of the executed command, with each
81
+ line separated by a newline character.
82
+ """
83
+ stdout, _ = run_indent(cmd=cmd, **kwargs)
84
+ return "\n".join(stdout) + "\n"
85
+
86
+
87
+ def indent_raw_output(raw_output: List[str], spaces: int = 4) -> List[str]:
88
+ """
89
+ Indents each line of the given list of strings.
90
+
91
+ :param raw_output: List of strings to indent.
92
+ :param spaces: Number of spaces to indent each line.
93
+ :return: A new list of strings with each line indented as specified.
94
+ """
95
+ indentation = ' ' * spaces
96
+ return [indentation + line for line in raw_output]
97
+
98
+
99
+ def run_indent(cmd: Union[List[str], str], spaces: int = 4,
100
+ **kwargs) -> Tuple[List[str], List[str]]:
101
+ """
102
+ Executes a command and returns its stdout and stderr output, both indented.
103
+
104
+ :param cmd: Command to be executed, either as a list of strings or a single
105
+ string.
106
+ :param spaces: Number of spaces to use for indentation.
107
+ :param kwargs: Additional keyword arguments for `run_simple`.
108
+ :return: A tuple containing two lists: indented stdout and stderr lines.
109
+ """
110
+ cmd = shlex.split(cmd) if isinstance(cmd, str) else cmd
111
+ stdout, stderr = run_simple(cmd=cmd, **kwargs)
112
+ stdout = indent_raw_output([f"[RUN] {list2cmdline(cmd)}"], spaces) + \
113
+ indent_raw_output(stdout, spaces)
114
+ stderr = indent_raw_output(stderr, spaces)
115
+
116
+ return (stdout, stderr)