githubtakeout 0.1.4__tar.gz → 0.1.6__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.4
3
+ Version: 0.1.6
4
4
  Summary: Archive public Git Repos and Gists from GitHub
5
5
  Author: Corey Goldberg
6
6
  Maintainer: Corey Goldberg
@@ -20,6 +20,7 @@ Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: GitPython==3.1.44
22
22
  Requires-Dist: PyGithub==2.6.1
23
+ Requires-Dist: rich==14.0.0
23
24
  Dynamic: license-file
24
25
 
25
26
  # githubtakeout
@@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "githubtakeout"
7
- version = "0.1.4"
7
+ version = "0.1.6"
8
8
  description = "Archive public Git Repos and Gists from GitHub"
9
9
  license = "MIT"
10
10
  license-files = ["LICENSE"]
11
11
  authors = [{name = "Corey Goldberg"}]
12
12
  maintainers = [{name = "Corey Goldberg"}]
13
13
  readme = "README.md"
14
- requires-python = ">= 3.12"
14
+ requires-python = ">=3.12"
15
15
  keywords = ["git", "github", "archive", "backup", "export", "takeout"]
16
16
  classifiers = [
17
17
  "Environment :: Console",
@@ -19,11 +19,12 @@ classifiers = [
19
19
  "Programming Language :: Python",
20
20
  "Programming Language :: Python :: 3",
21
21
  "Programming Language :: Python :: 3.12",
22
- "Programming Language :: Python :: 3.13"
22
+ "Programming Language :: Python :: 3.13",
23
23
  ]
24
24
  dependencies = [
25
25
  "GitPython==3.1.44",
26
- "PyGithub==2.6.1"
26
+ "PyGithub==2.6.1",
27
+ "rich==14.0.0",
27
28
  ]
28
29
 
29
30
  [project.scripts]
@@ -33,3 +34,23 @@ githubtakeout = "githubtakeout:main"
33
34
  homepage = "https://github.com/cgoldberg/githubtakeout"
34
35
  source = "https://github.com/cgoldberg/githubtakeout"
35
36
  download = "https://pypi.org/project/githubtakeout"
37
+
38
+ [tool.setuptools]
39
+ package-dir = {"" = "src"}
40
+
41
+ [tool.black]
42
+ line-length = 88
43
+ target-version = ["py312"]
44
+
45
+ [tool.ruff]
46
+ line-length = 88
47
+ respect-gitignore = true
48
+ target-version = "py312"
49
+
50
+ [tool.ruff.lint]
51
+ extend-select = ["E4", "E7", "E9", "F", "I", "E501", "RUF022"]
52
+ fixable = ["ALL"]
53
+
54
+ [tool.ruff.format]
55
+ docstring-code-format = true
56
+ docstring-code-line-length = 88
File without changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: githubtakeout
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Archive public Git Repos and Gists from GitHub
5
5
  Author: Corey Goldberg
6
6
  Maintainer: Corey Goldberg
@@ -20,6 +20,7 @@ Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: GitPython==3.1.44
22
22
  Requires-Dist: PyGithub==2.6.1
23
+ Requires-Dist: rich==14.0.0
23
24
  Dynamic: license-file
24
25
 
25
26
  # githubtakeout
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/__init__.py
5
+ src/githubtakeout.py
6
+ src/progress.py
7
+ src/githubtakeout.egg-info/PKG-INFO
8
+ src/githubtakeout.egg-info/SOURCES.txt
9
+ src/githubtakeout.egg-info/dependency_links.txt
10
+ src/githubtakeout.egg-info/entry_points.txt
11
+ src/githubtakeout.egg-info/requires.txt
12
+ src/githubtakeout.egg-info/top_level.txt
@@ -1,2 +1,3 @@
1
1
  GitPython==3.1.44
2
2
  PyGithub==2.6.1
3
+ rich==14.0.0
@@ -0,0 +1,3 @@
1
+ __init__
2
+ githubtakeout
3
+ progress
@@ -1,8 +1,12 @@
1
- #!/usr/bin/env python3
1
+ # Corey Goldberg, 2025
2
+ # License: MIT
3
+
4
+ """Archive Git Repos and Gists from GitHub"""
2
5
 
3
6
  import argparse
4
7
  import getpass
5
8
  import logging
9
+ import math
6
10
  import os
7
11
  import shutil
8
12
  import stat
@@ -10,47 +14,57 @@ import sys
10
14
  import tarfile
11
15
  import urllib
12
16
  import zipfile
13
-
14
17
  from pathlib import Path
15
18
  from timeit import default_timer
16
19
 
17
20
  import git
18
21
  import github
19
22
 
23
+ from progress import GitProgress
20
24
 
21
- logging.basicConfig(level=logging.INFO, format='%(message)s')
25
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
22
26
  logger = logging.getLogger(__name__)
23
27
 
24
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
+
25
39
  def add_creds(url, username, token):
26
40
  if token is None:
27
41
  new_url = url
28
42
  else:
29
43
  username = urllib.parse.quote(username)
30
44
  url_parts = urllib.parse.urlparse(url)
31
- netloc = f'{username}:{token}@{url_parts.netloc}'
45
+ netloc = f"{username}:{token}@{url_parts.netloc}"
32
46
  new_url = urllib.parse.urlunparse(url_parts._replace(netloc=netloc))
33
47
  return new_url
34
48
 
35
49
 
36
- def archive(local_repo_dir, archive_format='zip', is_gist=False):
37
- if archive_format not in ('tar', 'zip'):
38
- raise ValueError(f'{archive_format} is not a valid archive format')
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")
39
53
  base_name = os.path.basename(local_repo_dir)
40
- extension = 'tar.gz' if archive_format == 'tar' else archive_format
41
- archive_name = f'{base_name}.{extension}'
54
+ extension = "tar.gz" if archive_format == "tar" else archive_format
55
+ archive_name = f"{base_name}.{extension}"
42
56
  if is_gist:
43
- archive_name = f'gist-{archive_name}'
57
+ archive_name = f"gist-{archive_name}"
44
58
  parent_dir = os.path.dirname(local_repo_dir)
45
59
  archive_path = os.path.join(parent_dir, archive_name)
46
- logger.info(f'creating archive: {archive_path}')
47
- if archive_format == 'tar':
48
- with tarfile.open(archive_path, 'w:gz') as tar_archive:
60
+ logger.info(f"creating archive: {archive_path}")
61
+ if archive_format == "tar":
62
+ with tarfile.open(archive_path, "w:gz") as tar_archive:
49
63
  tar_archive.add(local_repo_dir, arcname=base_name)
50
- elif archive_format == 'zip':
51
- with zipfile.ZipFile(archive_path, 'w', zipfile.ZIP_DEFLATED) as zip_archive:
64
+ elif archive_format == "zip":
65
+ with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as zip_archive:
52
66
  repo_path = Path(local_repo_dir)
53
- for entry in repo_path.rglob('*'):
67
+ for entry in repo_path.rglob("*"):
54
68
  path = os.path.join(base_name, entry.relative_to(repo_path))
55
69
  zip_archive.write(entry, arcname=path)
56
70
  return archive_path
@@ -60,13 +74,17 @@ def clone(repo_url, local_repo_dir, include_history):
60
74
  try:
61
75
  if include_history:
62
76
  # full clone
63
- repo = git.Repo.clone_from(repo_url, local_repo_dir)
77
+ repo = git.Repo.clone_from(
78
+ url=repo_url, to_path=local_repo_dir, progress=GitProgress()
79
+ )
64
80
  else:
65
81
  # shallow clone (no commit history or branches)
66
- repo = git.Repo.clone_from(repo_url, local_repo_dir, multi_options=['--depth=1'])
82
+ repo = git.Repo.clone_from(
83
+ url=repo_url, to_path=local_repo_dir, depth=1, progress=GitProgress()
84
+ )
67
85
  except git.GitCommandError as e:
68
86
  logger.error(e)
69
- sys.exit('Error: failed cloning repo')
87
+ sys.exit("error: failed cloning repo")
70
88
  finally:
71
89
  try:
72
90
  # release resources
@@ -76,8 +94,9 @@ def clone(repo_url, local_repo_dir, include_history):
76
94
  pass
77
95
 
78
96
 
79
-
80
- def clone_and_archive_repo(repo_url, local_repo_dir, archive_format, include_history, is_gist=False):
97
+ def clone_and_archive_repo(
98
+ repo_url, local_repo_dir, archive_format, include_history, is_gist=False
99
+ ):
81
100
  def remove_readonly(func, path, _):
82
101
  # This is necessary so rmtree() doesn't fail if there are any readonly
83
102
  # dirs/files when trying to delete. This seems to happen after cloning on
@@ -90,26 +109,27 @@ def clone_and_archive_repo(repo_url, local_repo_dir, archive_format, include_his
90
109
  os.chmod(path, stat.S_IWRITE)
91
110
  # try again
92
111
  func(path)
112
+
93
113
  try:
94
114
  # delete repo if it already exists
95
115
  shutil.rmtree(local_repo_dir, onexc=remove_readonly)
96
116
  except FileNotFoundError:
97
117
  pass
98
- repo_name = urllib.parse.urlparse(repo_url).path.lstrip('/')
99
- logger.info(f'cloning repo: {repo_name} to {local_repo_dir}')
118
+ repo_name = urllib.parse.urlparse(repo_url).path.lstrip("/")
119
+ logger.info(f"cloning repo: {repo_name} to {local_repo_dir}")
100
120
  start = default_timer()
101
121
  clone(repo_url, local_repo_dir, include_history)
102
122
  if not include_history:
103
123
  # delete the .git directory if we are not saving history
104
124
  try:
105
- git_dir = os.path.join(local_repo_dir, '.git')
125
+ git_dir = os.path.join(local_repo_dir, ".git")
106
126
  shutil.rmtree(git_dir, onexc=remove_readonly)
107
127
  except FileNotFoundError:
108
128
  pass
109
129
  archive_path = archive(local_repo_dir, archive_format, is_gist)
110
- size_kb = os.path.getsize(archive_path) / 1024
111
- logger.info(f'archive size: {size_kb:.2f} KB')
112
- logger.info('deleting repo')
130
+ size = convert_size(os.path.getsize(archive_path))
131
+ logger.info(f"archive size: {size}")
132
+ logger.info("deleting repo")
113
133
  try:
114
134
  # delete repo after archive is created
115
135
  shutil.rmtree(local_repo_dir, onexc=remove_readonly)
@@ -117,18 +137,18 @@ def clone_and_archive_repo(repo_url, local_repo_dir, archive_format, include_his
117
137
  pass
118
138
  elapsed = default_timer() - start
119
139
  base_name = os.path.basename(local_repo_dir)
120
- logger.info(f'successfully backed up "{base_name}" repo in {elapsed:.3f} secs\n')
140
+ logger.info(f"successfully backed up '{base_name}' repo in {elapsed:.3f} secs\n")
121
141
 
122
142
 
123
143
  def get_user(username, prompt_token):
124
144
  if prompt_token:
125
- token = getpass.getpass('Token:')
145
+ token = getpass.getpass("Token:")
126
146
  if not token:
127
- sys.exit('Error: auth token cannot be empty')
147
+ sys.exit("error: auth token cannot be empty")
128
148
  auth = github.Auth.Token(token)
129
149
  gh = github.Github(auth=auth)
130
150
  else:
131
- token = os.getenv('GITHUB_TOKEN')
151
+ token = os.getenv("GITHUB_TOKEN")
132
152
  if token is not None:
133
153
  auth = github.Auth.Token(token)
134
154
  gh = github.Github(auth=auth)
@@ -138,8 +158,8 @@ def get_user(username, prompt_token):
138
158
  try:
139
159
  user = gh.get_user(username)
140
160
  except github.GithubException as e:
141
- if e.data['status'] == '404':
142
- sys.exit(f'Error: user "{username}" not found on GitHub')
161
+ if e.data["status"] == "404":
162
+ sys.exit(f"error: user '{username}' not found")
143
163
  else:
144
164
  raise e
145
165
  repos = user.get_repos()
@@ -147,42 +167,45 @@ def get_user(username, prompt_token):
147
167
  # you need to be authenticated and then call the API
148
168
  # with no username to get private repos
149
169
  user = gh.get_user()
150
- repos = user.get_repos(affiliation='owner')
170
+ repos = user.get_repos(affiliation="owner")
151
171
  try:
152
172
  # this just makes an API request so we can exit if unauthorized
153
173
  _ = repos.totalCount
154
174
  except github.GithubException as e:
155
- if e.data['status'] == '401':
156
- sys.exit(f'Error: invalid auth token for user "{username}" on GitHub')
175
+ if e.data["status"] == "401":
176
+ sys.exit(f"error: invalid auth token for user '{username}'")
157
177
  else:
158
178
  raise e
159
179
  gists = user.get_gists()
160
180
  return user, repos, gists, token
161
181
 
162
182
 
163
- def run(username, base_dir, archive_format, include_gists, include_history, list_only, prompt_token):
164
- working_dir = os.path.join(base_dir, 'backups')
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")
165
193
  user, repos, gists, token = get_user(username, prompt_token)
166
194
  num_repos = repos.totalCount
167
195
  if not list_only:
168
- logger.info(f'creating archives in: {working_dir}\n')
169
- logger.info(f'found {num_repos} repos for user "{username}"\n')
196
+ logger.info(f"creating archives in: {working_dir}\n")
197
+ logger.info(f"found {num_repos} repos for user '{username}'\n")
170
198
  for repo in repos:
171
199
  local_repo_dir = os.path.join(working_dir, repo.name)
172
200
  url = add_creds(repo.clone_url, username, token)
173
201
  if list_only:
174
202
  logger.info(url)
175
203
  else:
176
- clone_and_archive_repo(
177
- url,
178
- local_repo_dir,
179
- archive_format,
180
- include_history
181
- )
204
+ clone_and_archive_repo(url, local_repo_dir, archive_format, include_history)
182
205
  if include_gists:
183
206
  num_gists = gists.totalCount
184
- logger.info('')
185
- logger.info(f'found {num_gists} gists for user "{username}"\n')
207
+ logger.info("")
208
+ logger.info(f"found {num_gists} gists for user '{username}'\n")
186
209
  for gist in gists:
187
210
  local_repo_dir = os.path.join(working_dir, gist.id)
188
211
  url = add_creds(gist.git_pull_url, username, token)
@@ -190,55 +213,31 @@ def run(username, base_dir, archive_format, include_gists, include_history, list
190
213
  logger.info(url)
191
214
  else:
192
215
  clone_and_archive_repo(
193
- url,
194
- local_repo_dir,
195
- archive_format,
196
- include_history,
197
- is_gist=True
216
+ url, local_repo_dir, archive_format, include_history, is_gist=True
198
217
  )
199
218
 
200
219
 
201
220
  def main():
202
221
  if sys.version_info < (3, 12):
203
- sys.exit('sorry, this program requires Python 3.12+')
222
+ sys.exit("sorry, this program requires Python 3.12+")
204
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)")
205
227
  parser.add_argument(
206
- 'username',
207
- help='github username'
208
- )
209
- parser.add_argument(
210
- '--dir',
211
- default=os.getcwd(),
212
- help='output directory'
213
- )
214
- parser.add_argument(
215
- '--format',
216
- default='zip',
217
- help='archive format (tar, zip)'
218
- )
219
- parser.add_argument(
220
- '--gists',
221
- action='store_true',
222
- default=False,
223
- help='include gists'
228
+ "--gists", action="store_true", default=False, help="include gists"
224
229
  )
225
230
  parser.add_argument(
226
- '--history',
227
- action='store_true',
231
+ "--history",
232
+ action="store_true",
228
233
  default=False,
229
- help='include commit history and branches (.git directory)'
234
+ help="include commit history and branches (.git directory)",
230
235
  )
231
236
  parser.add_argument(
232
- '--list',
233
- action='store_true',
234
- default=False,
235
- help='list repos only'
237
+ "--list", action="store_true", default=False, help="list repos only"
236
238
  )
237
239
  parser.add_argument(
238
- '--token',
239
- action='store_true',
240
- default=False,
241
- help='prompt for auth token'
240
+ "--token", action="store_true", default=False, help="prompt for auth token"
242
241
  )
243
242
  args = parser.parse_args()
244
243
  try:
@@ -249,11 +248,7 @@ def main():
249
248
  include_gists=args.gists,
250
249
  include_history=args.history,
251
250
  list_only=args.list,
252
- prompt_token=args.token
251
+ prompt_token=args.token,
253
252
  )
254
253
  except KeyboardInterrupt:
255
- sys.exit('\nexiting program ...')
256
-
257
-
258
- if __name__ == '__main__':
259
- main()
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,10 +0,0 @@
1
- LICENSE
2
- README.md
3
- githubtakeout.py
4
- pyproject.toml
5
- githubtakeout.egg-info/PKG-INFO
6
- githubtakeout.egg-info/SOURCES.txt
7
- githubtakeout.egg-info/dependency_links.txt
8
- githubtakeout.egg-info/entry_points.txt
9
- githubtakeout.egg-info/requires.txt
10
- githubtakeout.egg-info/top_level.txt
@@ -1 +0,0 @@
1
- githubtakeout
File without changes
File without changes
File without changes