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.
- batchfetch/__init__.py +23 -0
- batchfetch/batchfetch_base.py +192 -0
- batchfetch/batchfetch_cli.py +249 -0
- batchfetch/batchfetch_git.py +329 -0
- batchfetch/helpers.py +116 -0
- batchfetch-1.0.0.dist-info/LICENSE +674 -0
- batchfetch-1.0.0.dist-info/METADATA +76 -0
- batchfetch-1.0.0.dist-info/RECORD +11 -0
- batchfetch-1.0.0.dist-info/WHEEL +5 -0
- batchfetch-1.0.0.dist-info/entry_points.txt +2 -0
- batchfetch-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|