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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: githubtakeout
3
- Version: 0.1.9
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.9"
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 = 88
42
+ line-length = 120
44
43
  target-version = ["py39", "py310", "py311", "py312", "py313"]
45
44
 
46
45
  [tool.ruff]
47
- line-length = 88
46
+ line-length = 120
48
47
  respect-gitignore = true
49
48
  target-version = "py312"
50
49
 
51
50
  [tool.ruff.lint]
52
- extend-select = ["E4", "E7", "E9", "F", "I", "E501", "RUF022"]
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 = 88
62
+ docstring-code-line-length = 120
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: githubtakeout
3
- Version: 0.1.9
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,4 +1,3 @@
1
1
  GitPython==3.1.45
2
2
  PyGithub==2.8.1
3
3
  rich==14.1.0
4
- tenacity==9.1.2
@@ -1,7 +1,7 @@
1
- # Corey Goldberg, 2025
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 = int(math.floor(math.log(size_bytes, 1024)))
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
- @retry(stop=stop_after_attempt(5), wait=wait_fixed(2))
153
- def count_repos(repos):
154
- return repos.totalCount
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
- @retry(stop=stop_after_attempt(5), wait=wait_fixed(2))
158
- def get_user(username, prompt_token):
159
- if prompt_token:
160
- token = getpass.getpass("Token:")
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
- token = os.getenv("GITHUB_TOKEN")
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
- # you need to be authenticated and then call the API
183
- # with no username to get private repos
184
- user = gh.get_user()
185
- repos = user.get_repos(affiliation="owner")
186
- try:
187
- # this just makes an API request so we can exit if unauthorized
188
- _ = count_repos(repos)
189
- except github.GithubException as e:
190
- if e.data["status"] == "401":
191
- sys.exit(f"error: invalid auth token for user '{username}'")
192
- else:
193
- raise e
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
- prompt_token,
198
+ prompt_for_token,
206
199
  ):
207
200
  working_dir = os.path.join(base_dir, "backups")
208
- user, repos, gists, token = get_user(username, prompt_token)
209
- num_repos = count_repos(repos)
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 include_gists:
221
- num_gists = count_repos(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
- "--list", action="store_true", default=False, help="list repos only"
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
- prompt_token=args.token,
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