githubtakeout 0.1.9__tar.gz → 0.1.11__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.9/src/githubtakeout.egg-info → githubtakeout-0.1.11}/PKG-INFO +1 -2
- {githubtakeout-0.1.9 → githubtakeout-0.1.11}/pyproject.toml +11 -6
- {githubtakeout-0.1.9 → githubtakeout-0.1.11/src/githubtakeout.egg-info}/PKG-INFO +1 -2
- {githubtakeout-0.1.9 → githubtakeout-0.1.11}/src/githubtakeout.egg-info/requires.txt +0 -1
- {githubtakeout-0.1.9 → githubtakeout-0.1.11}/src/githubtakeout.py +85 -99
- {githubtakeout-0.1.9 → githubtakeout-0.1.11}/src/progress.py +7 -5
- {githubtakeout-0.1.9 → githubtakeout-0.1.11}/LICENSE +0 -0
- {githubtakeout-0.1.9 → githubtakeout-0.1.11}/README.md +0 -0
- {githubtakeout-0.1.9 → githubtakeout-0.1.11}/setup.cfg +0 -0
- {githubtakeout-0.1.9 → githubtakeout-0.1.11}/src/githubtakeout.egg-info/SOURCES.txt +0 -0
- {githubtakeout-0.1.9 → githubtakeout-0.1.11}/src/githubtakeout.egg-info/dependency_links.txt +0 -0
- {githubtakeout-0.1.9 → githubtakeout-0.1.11}/src/githubtakeout.egg-info/entry_points.txt +0 -0
- {githubtakeout-0.1.9 → githubtakeout-0.1.11}/src/githubtakeout.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: githubtakeout
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.11
|
|
4
4
|
Summary: Archive public Git Repos and Gists from GitHub
|
|
5
5
|
Author: Corey Goldberg
|
|
6
6
|
Maintainer: Corey Goldberg
|
|
@@ -21,7 +21,6 @@ License-File: LICENSE
|
|
|
21
21
|
Requires-Dist: GitPython==3.1.45
|
|
22
22
|
Requires-Dist: PyGithub==2.8.1
|
|
23
23
|
Requires-Dist: rich==14.1.0
|
|
24
|
-
Requires-Dist: tenacity==9.1.2
|
|
25
24
|
Dynamic: license-file
|
|
26
25
|
|
|
27
26
|
# githubtakeout
|
|
@@ -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.11"
|
|
8
8
|
description = "Archive public Git Repos and Gists from GitHub"
|
|
9
9
|
license = "MIT"
|
|
10
10
|
license-files = ["LICENSE"]
|
|
@@ -25,7 +25,6 @@ dependencies = [
|
|
|
25
25
|
"GitPython==3.1.45",
|
|
26
26
|
"PyGithub==2.8.1",
|
|
27
27
|
"rich==14.1.0",
|
|
28
|
-
"tenacity==9.1.2",
|
|
29
28
|
]
|
|
30
29
|
|
|
31
30
|
[project.scripts]
|
|
@@ -40,18 +39,24 @@ download = "https://pypi.org/project/githubtakeout"
|
|
|
40
39
|
package-dir = {"" = "src"}
|
|
41
40
|
|
|
42
41
|
[tool.black]
|
|
43
|
-
line-length =
|
|
42
|
+
line-length = 120
|
|
44
43
|
target-version = ["py39", "py310", "py311", "py312", "py313"]
|
|
45
44
|
|
|
46
45
|
[tool.ruff]
|
|
47
|
-
line-length =
|
|
46
|
+
line-length = 120
|
|
48
47
|
respect-gitignore = true
|
|
49
48
|
target-version = "py312"
|
|
50
49
|
|
|
51
50
|
[tool.ruff.lint]
|
|
52
|
-
|
|
51
|
+
select = [
|
|
52
|
+
"D", "E", "F", "I", "PT", "RUF", "TID", "W"
|
|
53
|
+
]
|
|
54
|
+
extend-ignore = [
|
|
55
|
+
"D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107", "D203",
|
|
56
|
+
"D205", "D211", "D213", "D400", "D401", "RUF012"
|
|
57
|
+
]
|
|
53
58
|
fixable = ["ALL"]
|
|
54
59
|
|
|
55
60
|
[tool.ruff.format]
|
|
56
61
|
docstring-code-format = true
|
|
57
|
-
docstring-code-line-length =
|
|
62
|
+
docstring-code-line-length = 120
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: githubtakeout
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.11
|
|
4
4
|
Summary: Archive public Git Repos and Gists from GitHub
|
|
5
5
|
Author: Corey Goldberg
|
|
6
6
|
Maintainer: Corey Goldberg
|
|
@@ -21,7 +21,6 @@ License-File: LICENSE
|
|
|
21
21
|
Requires-Dist: GitPython==3.1.45
|
|
22
22
|
Requires-Dist: PyGithub==2.8.1
|
|
23
23
|
Requires-Dist: rich==14.1.0
|
|
24
|
-
Requires-Dist: tenacity==9.1.2
|
|
25
24
|
Dynamic: license-file
|
|
26
25
|
|
|
27
26
|
# githubtakeout
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
# Corey Goldberg
|
|
1
|
+
# Copyright (c) 2015-2025 Corey Goldberg
|
|
2
2
|
# License: MIT
|
|
3
3
|
|
|
4
|
-
"""Archive Git Repos and Gists from GitHub"""
|
|
4
|
+
"""Archive Git Repos and Gists from GitHub."""
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
import getpass
|
|
@@ -19,7 +19,6 @@ from timeit import default_timer
|
|
|
19
19
|
|
|
20
20
|
import git
|
|
21
21
|
import github
|
|
22
|
-
from tenacity import retry, stop_after_attempt, wait_fixed
|
|
23
22
|
|
|
24
23
|
from progress import GitProgress
|
|
25
24
|
|
|
@@ -33,7 +32,7 @@ def convert_size(size_bytes):
|
|
|
33
32
|
if size_bytes == 0:
|
|
34
33
|
return "0 B"
|
|
35
34
|
size_name = ("B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB")
|
|
36
|
-
i =
|
|
35
|
+
i = math.floor(math.log(size_bytes, 1024))
|
|
37
36
|
p = math.pow(1024, i)
|
|
38
37
|
s = round(size_bytes / p, 2)
|
|
39
38
|
return f"{s} {size_name[i]}"
|
|
@@ -50,52 +49,6 @@ def add_creds(url, username, token):
|
|
|
50
49
|
return new_url
|
|
51
50
|
|
|
52
51
|
|
|
53
|
-
def clone_and_archive_repo(
|
|
54
|
-
repo_url, local_repo_dir, archive_format, include_history, is_gist=False
|
|
55
|
-
):
|
|
56
|
-
def remove_readonly(func, path, _):
|
|
57
|
-
# This is necessary so rmtree() doesn't fail if there are any readonly
|
|
58
|
-
# dirs/files when trying to delete. This seems to happen after cloning on
|
|
59
|
-
# Windows. When any error occurs during deletion, we change the permissions
|
|
60
|
-
# and and reattempt removal.
|
|
61
|
-
#
|
|
62
|
-
# give read permissions
|
|
63
|
-
os.chmod(path, stat.S_IREAD)
|
|
64
|
-
# give write permissions
|
|
65
|
-
os.chmod(path, stat.S_IWRITE)
|
|
66
|
-
# try again
|
|
67
|
-
func(path)
|
|
68
|
-
|
|
69
|
-
try:
|
|
70
|
-
# delete repo if it already exists
|
|
71
|
-
shutil.rmtree(local_repo_dir, onexc=remove_readonly)
|
|
72
|
-
except FileNotFoundError:
|
|
73
|
-
pass
|
|
74
|
-
repo_name = urllib.parse.urlparse(repo_url).path.lstrip("/")
|
|
75
|
-
logger.info(f"cloning repo: {repo_name} to {local_repo_dir}")
|
|
76
|
-
start = default_timer()
|
|
77
|
-
clone(repo_url, local_repo_dir, include_history)
|
|
78
|
-
if not include_history:
|
|
79
|
-
# delete the .git directory if we are not saving history
|
|
80
|
-
try:
|
|
81
|
-
git_dir = os.path.join(local_repo_dir, ".git")
|
|
82
|
-
shutil.rmtree(git_dir, onexc=remove_readonly)
|
|
83
|
-
except FileNotFoundError:
|
|
84
|
-
pass
|
|
85
|
-
archive_path = archive(local_repo_dir, archive_format, is_gist)
|
|
86
|
-
size = convert_size(os.path.getsize(archive_path))
|
|
87
|
-
logger.info(f"archive size: {size}")
|
|
88
|
-
logger.info("deleting repo")
|
|
89
|
-
try:
|
|
90
|
-
# delete repo after archive is created
|
|
91
|
-
shutil.rmtree(local_repo_dir, onexc=remove_readonly)
|
|
92
|
-
except FileNotFoundError:
|
|
93
|
-
pass
|
|
94
|
-
elapsed = default_timer() - start
|
|
95
|
-
base_name = os.path.basename(local_repo_dir)
|
|
96
|
-
logger.info(f"successfully backed up '{base_name}' repo in {elapsed:.3f} secs\n")
|
|
97
|
-
|
|
98
|
-
|
|
99
52
|
def archive(local_repo_dir, archive_format="zip", is_gist=False):
|
|
100
53
|
if archive_format not in ARCHIVE_FORMATS:
|
|
101
54
|
raise ValueError(f"{archive_format} is not a valid archive format")
|
|
@@ -119,7 +72,6 @@ def archive(local_repo_dir, archive_format="zip", is_gist=False):
|
|
|
119
72
|
return archive_path
|
|
120
73
|
|
|
121
74
|
|
|
122
|
-
@retry(stop=stop_after_attempt(5), wait=wait_fixed(2))
|
|
123
75
|
def clone(repo_url, local_repo_dir, include_history):
|
|
124
76
|
try:
|
|
125
77
|
if include_history:
|
|
@@ -149,27 +101,68 @@ def clone(repo_url, local_repo_dir, include_history):
|
|
|
149
101
|
pass
|
|
150
102
|
|
|
151
103
|
|
|
152
|
-
|
|
153
|
-
def
|
|
154
|
-
|
|
104
|
+
def clone_and_archive_repo(repo_url, local_repo_dir, archive_format, include_history, is_gist=False):
|
|
105
|
+
def remove_readonly(func, path, _):
|
|
106
|
+
# This is necessary so rmtree() doesn't fail if there are any readonly
|
|
107
|
+
# dirs/files when trying to delete. This seems to happen after cloning on
|
|
108
|
+
# Windows. When any error occurs during deletion, we change the permissions
|
|
109
|
+
# and and reattempt removal.
|
|
110
|
+
#
|
|
111
|
+
# give read permissions
|
|
112
|
+
os.chmod(path, stat.S_IREAD)
|
|
113
|
+
# give write permissions
|
|
114
|
+
os.chmod(path, stat.S_IWRITE)
|
|
115
|
+
# try again
|
|
116
|
+
func(path)
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
# delete repo if it already exists
|
|
120
|
+
shutil.rmtree(local_repo_dir, onexc=remove_readonly)
|
|
121
|
+
except FileNotFoundError:
|
|
122
|
+
pass
|
|
123
|
+
repo_name = urllib.parse.urlparse(repo_url).path.lstrip("/")
|
|
124
|
+
logger.info(f"cloning repo: {repo_name} to {local_repo_dir}")
|
|
125
|
+
start = default_timer()
|
|
126
|
+
clone(repo_url, local_repo_dir, include_history)
|
|
127
|
+
if not include_history:
|
|
128
|
+
# delete the .git directory if we are not saving history
|
|
129
|
+
try:
|
|
130
|
+
git_dir = os.path.join(local_repo_dir, ".git")
|
|
131
|
+
shutil.rmtree(git_dir, onexc=remove_readonly)
|
|
132
|
+
except FileNotFoundError:
|
|
133
|
+
pass
|
|
134
|
+
archive_path = archive(local_repo_dir, archive_format, is_gist)
|
|
135
|
+
size = convert_size(os.path.getsize(archive_path))
|
|
136
|
+
logger.info(f"archive size: {size}")
|
|
137
|
+
logger.info("deleting repo")
|
|
138
|
+
try:
|
|
139
|
+
# delete repo after archive is created
|
|
140
|
+
shutil.rmtree(local_repo_dir, onexc=remove_readonly)
|
|
141
|
+
except FileNotFoundError:
|
|
142
|
+
pass
|
|
143
|
+
elapsed = default_timer() - start
|
|
144
|
+
base_name = os.path.basename(local_repo_dir)
|
|
145
|
+
logger.info(f"successfully backed up '{base_name}' repo in {elapsed:.3f} secs\n")
|
|
155
146
|
|
|
156
147
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if not token:
|
|
162
|
-
sys.exit("error: auth token cannot be empty")
|
|
148
|
+
def get_repos(username, token, include_gists):
|
|
149
|
+
if token is not None:
|
|
150
|
+
# you need to be authenticated and then call the API
|
|
151
|
+
# with no username to get public and private repos
|
|
163
152
|
auth = github.Auth.Token(token)
|
|
164
153
|
gh = github.Github(auth=auth)
|
|
154
|
+
user = gh.get_user()
|
|
155
|
+
repos = user.get_repos(affiliation="owner")
|
|
156
|
+
try:
|
|
157
|
+
# this just makes an API request so we can exit if unauthorized
|
|
158
|
+
_ = repos.totalCount
|
|
159
|
+
except github.GithubException as e:
|
|
160
|
+
if e.data["status"] == "401":
|
|
161
|
+
sys.exit(f"error: invalid auth token for user '{username}'")
|
|
162
|
+
else:
|
|
163
|
+
raise e
|
|
165
164
|
else:
|
|
166
|
-
|
|
167
|
-
if token is not None:
|
|
168
|
-
auth = github.Auth.Token(token)
|
|
169
|
-
gh = github.Github(auth=auth)
|
|
170
|
-
else:
|
|
171
|
-
gh = github.Github()
|
|
172
|
-
if token is None:
|
|
165
|
+
gh = github.Github()
|
|
173
166
|
try:
|
|
174
167
|
user = gh.get_user(username)
|
|
175
168
|
except github.GithubException as e:
|
|
@@ -178,21 +171,21 @@ def get_user(username, prompt_token):
|
|
|
178
171
|
else:
|
|
179
172
|
raise e
|
|
180
173
|
repos = user.get_repos()
|
|
174
|
+
if include_gists:
|
|
175
|
+
gists = user.get_gists()
|
|
181
176
|
else:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
gists = user.get_gists()
|
|
195
|
-
return user, repos, gists, token
|
|
177
|
+
gists = None
|
|
178
|
+
return repos, gists
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def get_token(prompt_for_token):
|
|
182
|
+
if prompt_for_token:
|
|
183
|
+
token = getpass.getpass("Token:")
|
|
184
|
+
if not token:
|
|
185
|
+
sys.exit("error: auth token cannot be empty")
|
|
186
|
+
else:
|
|
187
|
+
token = os.getenv("GITHUB_TOKEN")
|
|
188
|
+
return token
|
|
196
189
|
|
|
197
190
|
|
|
198
191
|
def run(
|
|
@@ -202,11 +195,12 @@ def run(
|
|
|
202
195
|
include_gists,
|
|
203
196
|
include_history,
|
|
204
197
|
list_only,
|
|
205
|
-
|
|
198
|
+
prompt_for_token,
|
|
206
199
|
):
|
|
207
200
|
working_dir = os.path.join(base_dir, "backups")
|
|
208
|
-
|
|
209
|
-
|
|
201
|
+
token = get_token(prompt_for_token)
|
|
202
|
+
repos, gists = get_repos(username, token, include_gists)
|
|
203
|
+
num_repos = repos.totalCount
|
|
210
204
|
if not list_only:
|
|
211
205
|
logger.info(f"creating archives in: {working_dir}\n")
|
|
212
206
|
logger.info(f"found {num_repos} repos for user '{username}'\n")
|
|
@@ -217,8 +211,8 @@ def run(
|
|
|
217
211
|
logger.info(url)
|
|
218
212
|
else:
|
|
219
213
|
clone_and_archive_repo(url, local_repo_dir, archive_format, include_history)
|
|
220
|
-
if
|
|
221
|
-
num_gists =
|
|
214
|
+
if gists is not None:
|
|
215
|
+
num_gists = gists.totalCount
|
|
222
216
|
logger.info("")
|
|
223
217
|
logger.info(f"found {num_gists} gists for user '{username}'\n")
|
|
224
218
|
for gist in gists:
|
|
@@ -227,9 +221,7 @@ def run(
|
|
|
227
221
|
if list_only:
|
|
228
222
|
logger.info(url)
|
|
229
223
|
else:
|
|
230
|
-
clone_and_archive_repo(
|
|
231
|
-
url, local_repo_dir, archive_format, include_history, is_gist=True
|
|
232
|
-
)
|
|
224
|
+
clone_and_archive_repo(url, local_repo_dir, archive_format, include_history, is_gist=True)
|
|
233
225
|
|
|
234
226
|
|
|
235
227
|
def main():
|
|
@@ -248,21 +240,15 @@ def main():
|
|
|
248
240
|
default="zip",
|
|
249
241
|
help="archive format (default: %(default)s)",
|
|
250
242
|
)
|
|
251
|
-
parser.add_argument(
|
|
252
|
-
"--gists", action="store_true", default=False, help="include gists"
|
|
253
|
-
)
|
|
243
|
+
parser.add_argument("--gists", action="store_true", default=False, help="include gists")
|
|
254
244
|
parser.add_argument(
|
|
255
245
|
"--history",
|
|
256
246
|
action="store_true",
|
|
257
247
|
default=False,
|
|
258
248
|
help="include commit history and branches (.git directory)",
|
|
259
249
|
)
|
|
260
|
-
parser.add_argument(
|
|
261
|
-
|
|
262
|
-
)
|
|
263
|
-
parser.add_argument(
|
|
264
|
-
"--token", action="store_true", default=False, help="prompt for auth token"
|
|
265
|
-
)
|
|
250
|
+
parser.add_argument("--list", action="store_true", default=False, help="list repos only")
|
|
251
|
+
parser.add_argument("--token", action="store_true", default=False, help="prompt for auth token")
|
|
266
252
|
args = parser.parse_args()
|
|
267
253
|
try:
|
|
268
254
|
run(
|
|
@@ -272,7 +258,7 @@ def main():
|
|
|
272
258
|
include_gists=args.gists,
|
|
273
259
|
include_history=args.history,
|
|
274
260
|
list_only=args.list,
|
|
275
|
-
|
|
261
|
+
prompt_for_token=args.token,
|
|
276
262
|
)
|
|
277
263
|
except KeyboardInterrupt:
|
|
278
264
|
sys.exit("\nexiting program ...")
|
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
# Copyright (c) 2025 Corey Goldberg
|
|
2
|
+
# License: MIT
|
|
3
|
+
# adapted from: https://stackoverflow.com/a/71285627/16148
|
|
4
|
+
|
|
5
|
+
"""Progress bar for cloning Git repos."""
|
|
6
|
+
|
|
1
7
|
import git
|
|
2
8
|
from rich import console, progress
|
|
3
9
|
|
|
4
|
-
# adapted from: https://stackoverflow.com/a/71285627/16148
|
|
5
|
-
|
|
6
10
|
|
|
7
11
|
class GitProgress(git.RemoteProgress):
|
|
8
12
|
OP_CODES = [
|
|
@@ -16,9 +20,7 @@ class GitProgress(git.RemoteProgress):
|
|
|
16
20
|
"RESOLVING",
|
|
17
21
|
"WRITING",
|
|
18
22
|
]
|
|
19
|
-
OP_CODE_MAP = {
|
|
20
|
-
getattr(git.RemoteProgress, _op_code): _op_code for _op_code in OP_CODES
|
|
21
|
-
}
|
|
23
|
+
OP_CODE_MAP = {getattr(git.RemoteProgress, _op_code): _op_code for _op_code in OP_CODES}
|
|
22
24
|
|
|
23
25
|
def __init__(self):
|
|
24
26
|
super().__init__()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{githubtakeout-0.1.9 → githubtakeout-0.1.11}/src/githubtakeout.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|