githubtakeout 0.1.5__tar.gz → 0.1.7__tar.gz
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.
- {githubtakeout-0.1.5/src/githubtakeout.egg-info → githubtakeout-0.1.7}/PKG-INFO +1 -1
- {githubtakeout-0.1.5 → githubtakeout-0.1.7}/pyproject.toml +3 -3
- {githubtakeout-0.1.5 → githubtakeout-0.1.7/src/githubtakeout.egg-info}/PKG-INFO +1 -1
- {githubtakeout-0.1.5 → githubtakeout-0.1.7}/src/githubtakeout.egg-info/SOURCES.txt +2 -0
- githubtakeout-0.1.7/src/githubtakeout.egg-info/top_level.txt +2 -0
- githubtakeout-0.1.7/src/githubtakeout.py +254 -0
- githubtakeout-0.1.7/src/progress.py +73 -0
- githubtakeout-0.1.5/src/githubtakeout.egg-info/top_level.txt +0 -1
- {githubtakeout-0.1.5 → githubtakeout-0.1.7}/LICENSE +0 -0
- {githubtakeout-0.1.5 → githubtakeout-0.1.7}/README.md +0 -0
- {githubtakeout-0.1.5 → githubtakeout-0.1.7}/setup.cfg +0 -0
- {githubtakeout-0.1.5 → githubtakeout-0.1.7}/src/githubtakeout.egg-info/dependency_links.txt +0 -0
- {githubtakeout-0.1.5 → githubtakeout-0.1.7}/src/githubtakeout.egg-info/entry_points.txt +0 -0
- {githubtakeout-0.1.5 → githubtakeout-0.1.7}/src/githubtakeout.egg-info/requires.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "githubtakeout"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.7"
|
|
8
8
|
description = "Archive public Git Repos and Gists from GitHub"
|
|
9
9
|
license = "MIT"
|
|
10
10
|
license-files = ["LICENSE"]
|
|
@@ -35,8 +35,8 @@ homepage = "https://github.com/cgoldberg/githubtakeout"
|
|
|
35
35
|
source = "https://github.com/cgoldberg/githubtakeout"
|
|
36
36
|
download = "https://pypi.org/project/githubtakeout"
|
|
37
37
|
|
|
38
|
-
[tool.setuptools
|
|
39
|
-
|
|
38
|
+
[tool.setuptools]
|
|
39
|
+
package-dir = {"" = "src"}
|
|
40
40
|
|
|
41
41
|
[tool.black]
|
|
42
42
|
line-length = 88
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# Corey Goldberg, 2025
|
|
2
|
+
# License: MIT
|
|
3
|
+
|
|
4
|
+
"""Archive Git Repos and Gists from GitHub"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import getpass
|
|
8
|
+
import logging
|
|
9
|
+
import math
|
|
10
|
+
import os
|
|
11
|
+
import shutil
|
|
12
|
+
import stat
|
|
13
|
+
import sys
|
|
14
|
+
import tarfile
|
|
15
|
+
import urllib
|
|
16
|
+
import zipfile
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from timeit import default_timer
|
|
19
|
+
|
|
20
|
+
import git
|
|
21
|
+
import github
|
|
22
|
+
|
|
23
|
+
from progress import GitProgress
|
|
24
|
+
|
|
25
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def convert_size(size_bytes):
|
|
30
|
+
if size_bytes == 0:
|
|
31
|
+
return "0 B"
|
|
32
|
+
size_name = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB")
|
|
33
|
+
i = int(math.floor(math.log(size_bytes, 1024)))
|
|
34
|
+
p = math.pow(1024, i)
|
|
35
|
+
s = round(size_bytes / p, 2)
|
|
36
|
+
return f"{s} {size_name[i]}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def add_creds(url, username, token):
|
|
40
|
+
if token is None:
|
|
41
|
+
new_url = url
|
|
42
|
+
else:
|
|
43
|
+
username = urllib.parse.quote(username)
|
|
44
|
+
url_parts = urllib.parse.urlparse(url)
|
|
45
|
+
netloc = f"{username}:{token}@{url_parts.netloc}"
|
|
46
|
+
new_url = urllib.parse.urlunparse(url_parts._replace(netloc=netloc))
|
|
47
|
+
return new_url
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def archive(local_repo_dir, archive_format="zip", is_gist=False):
|
|
51
|
+
if archive_format not in ("tar", "zip"):
|
|
52
|
+
raise ValueError(f"{archive_format} is not a valid archive format")
|
|
53
|
+
base_name = os.path.basename(local_repo_dir)
|
|
54
|
+
extension = "tar.gz" if archive_format == "tar" else archive_format
|
|
55
|
+
archive_name = f"{base_name}.{extension}"
|
|
56
|
+
if is_gist:
|
|
57
|
+
archive_name = f"gist-{archive_name}"
|
|
58
|
+
parent_dir = os.path.dirname(local_repo_dir)
|
|
59
|
+
archive_path = os.path.join(parent_dir, archive_name)
|
|
60
|
+
logger.info(f"creating archive: {archive_path}")
|
|
61
|
+
if archive_format == "tar":
|
|
62
|
+
with tarfile.open(archive_path, "w:gz") as tar_archive:
|
|
63
|
+
tar_archive.add(local_repo_dir, arcname=base_name)
|
|
64
|
+
elif archive_format == "zip":
|
|
65
|
+
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zip_archive:
|
|
66
|
+
repo_path = Path(local_repo_dir)
|
|
67
|
+
for entry in repo_path.rglob("*"):
|
|
68
|
+
path = os.path.join(base_name, entry.relative_to(repo_path))
|
|
69
|
+
zip_archive.write(entry, arcname=path)
|
|
70
|
+
return archive_path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def clone(repo_url, local_repo_dir, include_history):
|
|
74
|
+
try:
|
|
75
|
+
if include_history:
|
|
76
|
+
# full clone
|
|
77
|
+
repo = git.Repo.clone_from(
|
|
78
|
+
url=repo_url, to_path=local_repo_dir, progress=GitProgress()
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
# shallow clone (no commit history or branches)
|
|
82
|
+
repo = git.Repo.clone_from(
|
|
83
|
+
url=repo_url, to_path=local_repo_dir, depth=1, progress=GitProgress()
|
|
84
|
+
)
|
|
85
|
+
except git.GitCommandError as e:
|
|
86
|
+
logger.error(e)
|
|
87
|
+
sys.exit("error: failed cloning repo")
|
|
88
|
+
finally:
|
|
89
|
+
try:
|
|
90
|
+
# release resources
|
|
91
|
+
repo.close()
|
|
92
|
+
except UnboundLocalError:
|
|
93
|
+
# this occurs if we catch a signal while cloning
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def clone_and_archive_repo(
|
|
98
|
+
repo_url, local_repo_dir, archive_format, include_history, is_gist=False
|
|
99
|
+
):
|
|
100
|
+
def remove_readonly(func, path, _):
|
|
101
|
+
# This is necessary so rmtree() doesn't fail if there are any readonly
|
|
102
|
+
# dirs/files when trying to delete. This seems to happen after cloning on
|
|
103
|
+
# Windows. When any error occurs during deletion, we change the permissions
|
|
104
|
+
# and and reattempt removal.
|
|
105
|
+
#
|
|
106
|
+
# give read permissions
|
|
107
|
+
os.chmod(path, stat.S_IREAD)
|
|
108
|
+
# give write permissions
|
|
109
|
+
os.chmod(path, stat.S_IWRITE)
|
|
110
|
+
# try again
|
|
111
|
+
func(path)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
# delete repo if it already exists
|
|
115
|
+
shutil.rmtree(local_repo_dir, onexc=remove_readonly)
|
|
116
|
+
except FileNotFoundError:
|
|
117
|
+
pass
|
|
118
|
+
repo_name = urllib.parse.urlparse(repo_url).path.lstrip("/")
|
|
119
|
+
logger.info(f"cloning repo: {repo_name} to {local_repo_dir}")
|
|
120
|
+
start = default_timer()
|
|
121
|
+
clone(repo_url, local_repo_dir, include_history)
|
|
122
|
+
if not include_history:
|
|
123
|
+
# delete the .git directory if we are not saving history
|
|
124
|
+
try:
|
|
125
|
+
git_dir = os.path.join(local_repo_dir, ".git")
|
|
126
|
+
shutil.rmtree(git_dir, onexc=remove_readonly)
|
|
127
|
+
except FileNotFoundError:
|
|
128
|
+
pass
|
|
129
|
+
archive_path = archive(local_repo_dir, archive_format, is_gist)
|
|
130
|
+
size = convert_size(os.path.getsize(archive_path))
|
|
131
|
+
logger.info(f"archive size: {size}")
|
|
132
|
+
logger.info("deleting repo")
|
|
133
|
+
try:
|
|
134
|
+
# delete repo after archive is created
|
|
135
|
+
shutil.rmtree(local_repo_dir, onexc=remove_readonly)
|
|
136
|
+
except FileNotFoundError:
|
|
137
|
+
pass
|
|
138
|
+
elapsed = default_timer() - start
|
|
139
|
+
base_name = os.path.basename(local_repo_dir)
|
|
140
|
+
logger.info(f"successfully backed up '{base_name}' repo in {elapsed:.3f} secs\n")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_user(username, prompt_token):
|
|
144
|
+
if prompt_token:
|
|
145
|
+
token = getpass.getpass("Token:")
|
|
146
|
+
if not token:
|
|
147
|
+
sys.exit("error: auth token cannot be empty")
|
|
148
|
+
auth = github.Auth.Token(token)
|
|
149
|
+
gh = github.Github(auth=auth)
|
|
150
|
+
else:
|
|
151
|
+
token = os.getenv("GITHUB_TOKEN")
|
|
152
|
+
if token is not None:
|
|
153
|
+
auth = github.Auth.Token(token)
|
|
154
|
+
gh = github.Github(auth=auth)
|
|
155
|
+
else:
|
|
156
|
+
gh = github.Github()
|
|
157
|
+
if token is None:
|
|
158
|
+
try:
|
|
159
|
+
user = gh.get_user(username)
|
|
160
|
+
except github.GithubException as e:
|
|
161
|
+
if e.data["status"] == "404":
|
|
162
|
+
sys.exit(f"error: user '{username}' not found")
|
|
163
|
+
else:
|
|
164
|
+
raise e
|
|
165
|
+
repos = user.get_repos()
|
|
166
|
+
else:
|
|
167
|
+
# you need to be authenticated and then call the API
|
|
168
|
+
# with no username to get private repos
|
|
169
|
+
user = gh.get_user()
|
|
170
|
+
repos = user.get_repos(affiliation="owner")
|
|
171
|
+
try:
|
|
172
|
+
# this just makes an API request so we can exit if unauthorized
|
|
173
|
+
_ = repos.totalCount
|
|
174
|
+
except github.GithubException as e:
|
|
175
|
+
if e.data["status"] == "401":
|
|
176
|
+
sys.exit(f"error: invalid auth token for user '{username}'")
|
|
177
|
+
else:
|
|
178
|
+
raise e
|
|
179
|
+
gists = user.get_gists()
|
|
180
|
+
return user, repos, gists, token
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def run(
|
|
184
|
+
username,
|
|
185
|
+
base_dir,
|
|
186
|
+
archive_format,
|
|
187
|
+
include_gists,
|
|
188
|
+
include_history,
|
|
189
|
+
list_only,
|
|
190
|
+
prompt_token,
|
|
191
|
+
):
|
|
192
|
+
working_dir = os.path.join(base_dir, "backups")
|
|
193
|
+
user, repos, gists, token = get_user(username, prompt_token)
|
|
194
|
+
num_repos = repos.totalCount
|
|
195
|
+
if not list_only:
|
|
196
|
+
logger.info(f"creating archives in: {working_dir}\n")
|
|
197
|
+
logger.info(f"found {num_repos} repos for user '{username}'\n")
|
|
198
|
+
for repo in repos:
|
|
199
|
+
local_repo_dir = os.path.join(working_dir, repo.name)
|
|
200
|
+
url = add_creds(repo.clone_url, username, token)
|
|
201
|
+
if list_only:
|
|
202
|
+
logger.info(url)
|
|
203
|
+
else:
|
|
204
|
+
clone_and_archive_repo(url, local_repo_dir, archive_format, include_history)
|
|
205
|
+
if include_gists:
|
|
206
|
+
num_gists = gists.totalCount
|
|
207
|
+
logger.info("")
|
|
208
|
+
logger.info(f"found {num_gists} gists for user '{username}'\n")
|
|
209
|
+
for gist in gists:
|
|
210
|
+
local_repo_dir = os.path.join(working_dir, gist.id)
|
|
211
|
+
url = add_creds(gist.git_pull_url, username, token)
|
|
212
|
+
if list_only:
|
|
213
|
+
logger.info(url)
|
|
214
|
+
else:
|
|
215
|
+
clone_and_archive_repo(
|
|
216
|
+
url, local_repo_dir, archive_format, include_history, is_gist=True
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def main():
|
|
221
|
+
if sys.version_info < (3, 12):
|
|
222
|
+
sys.exit("sorry, this program requires Python 3.12+")
|
|
223
|
+
parser = argparse.ArgumentParser()
|
|
224
|
+
parser.add_argument("username", help="github username")
|
|
225
|
+
parser.add_argument("--dir", default=os.getcwd(), help="output directory")
|
|
226
|
+
parser.add_argument("--format", default="zip", help="archive format (tar, zip)")
|
|
227
|
+
parser.add_argument(
|
|
228
|
+
"--gists", action="store_true", default=False, help="include gists"
|
|
229
|
+
)
|
|
230
|
+
parser.add_argument(
|
|
231
|
+
"--history",
|
|
232
|
+
action="store_true",
|
|
233
|
+
default=False,
|
|
234
|
+
help="include commit history and branches (.git directory)",
|
|
235
|
+
)
|
|
236
|
+
parser.add_argument(
|
|
237
|
+
"--list", action="store_true", default=False, help="list repos only"
|
|
238
|
+
)
|
|
239
|
+
parser.add_argument(
|
|
240
|
+
"--token", action="store_true", default=False, help="prompt for auth token"
|
|
241
|
+
)
|
|
242
|
+
args = parser.parse_args()
|
|
243
|
+
try:
|
|
244
|
+
run(
|
|
245
|
+
username=args.username,
|
|
246
|
+
base_dir=args.dir,
|
|
247
|
+
archive_format=args.format,
|
|
248
|
+
include_gists=args.gists,
|
|
249
|
+
include_history=args.history,
|
|
250
|
+
list_only=args.list,
|
|
251
|
+
prompt_token=args.token,
|
|
252
|
+
)
|
|
253
|
+
except KeyboardInterrupt:
|
|
254
|
+
sys.exit("\nexiting program ...")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import git
|
|
2
|
+
from rich import console, progress
|
|
3
|
+
|
|
4
|
+
# adapted from: https://stackoverflow.com/a/71285627/16148
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GitProgress(git.RemoteProgress):
|
|
8
|
+
OP_CODES = [
|
|
9
|
+
"BEGIN",
|
|
10
|
+
"CHECKING_OUT",
|
|
11
|
+
"COMPRESSING",
|
|
12
|
+
"COUNTING",
|
|
13
|
+
"END",
|
|
14
|
+
"FINDING_SOURCES",
|
|
15
|
+
"RECEIVING",
|
|
16
|
+
"RESOLVING",
|
|
17
|
+
"WRITING",
|
|
18
|
+
]
|
|
19
|
+
OP_CODE_MAP = {
|
|
20
|
+
getattr(git.RemoteProgress, _op_code): _op_code for _op_code in OP_CODES
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def __init__(self):
|
|
24
|
+
super().__init__()
|
|
25
|
+
self.progressbar = progress.Progress(
|
|
26
|
+
progress.SpinnerColumn(),
|
|
27
|
+
progress.TextColumn("[progress.description]{task.description}"),
|
|
28
|
+
progress.BarColumn(),
|
|
29
|
+
progress.TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
30
|
+
"eta",
|
|
31
|
+
progress.TimeRemainingColumn(),
|
|
32
|
+
progress.TextColumn("{task.fields[message]}"),
|
|
33
|
+
console=console.Console(),
|
|
34
|
+
transient=False,
|
|
35
|
+
)
|
|
36
|
+
self.progressbar.start()
|
|
37
|
+
self.active_task = None
|
|
38
|
+
|
|
39
|
+
def __del__(self):
|
|
40
|
+
self.progressbar.stop()
|
|
41
|
+
|
|
42
|
+
@classmethod
|
|
43
|
+
def get_curr_op(cls, op_code):
|
|
44
|
+
"""Get OP name from OP code."""
|
|
45
|
+
# Remove BEGIN-flag and END-flag and get op name
|
|
46
|
+
op_code_masked = op_code & cls.OP_MASK
|
|
47
|
+
return cls.OP_CODE_MAP.get(op_code_masked, "?").title()
|
|
48
|
+
|
|
49
|
+
def update(self, op_code, cur_count, max_count=None, message=""):
|
|
50
|
+
# Start new bar on each BEGIN-flag
|
|
51
|
+
if op_code & self.BEGIN:
|
|
52
|
+
self.curr_op = self.get_curr_op(op_code)
|
|
53
|
+
if self.get_curr_op(op_code) == "Receiving":
|
|
54
|
+
self.active_task = self.progressbar.add_task(
|
|
55
|
+
description=self.curr_op,
|
|
56
|
+
total=max_count,
|
|
57
|
+
message=message,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if self.get_curr_op(op_code) == "Receiving":
|
|
61
|
+
self.progressbar.update(
|
|
62
|
+
task_id=self.active_task,
|
|
63
|
+
completed=cur_count,
|
|
64
|
+
message=message,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
# End progress monitoring on each END-flag
|
|
68
|
+
if self.get_curr_op(op_code) == "Receiving":
|
|
69
|
+
if op_code & self.END:
|
|
70
|
+
self.progressbar.update(
|
|
71
|
+
task_id=self.active_task,
|
|
72
|
+
message=f"[bright_black]{message}",
|
|
73
|
+
)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|