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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: githubtakeout
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Archive public Git Repos and Gists from GitHub
5
5
  Author: Corey Goldberg
6
6
  Maintainer: Corey Goldberg
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "githubtakeout"
7
- version = "0.1.5"
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.packages.find]
39
- where = ["src"]
38
+ [tool.setuptools]
39
+ package-dir = {"" = "src"}
40
40
 
41
41
  [tool.black]
42
42
  line-length = 88
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: githubtakeout
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Archive public Git Repos and Gists from GitHub
5
5
  Author: Corey Goldberg
6
6
  Maintainer: Corey Goldberg
@@ -1,6 +1,8 @@
1
1
  LICENSE
2
2
  README.md
3
3
  pyproject.toml
4
+ src/githubtakeout.py
5
+ src/progress.py
4
6
  src/githubtakeout.egg-info/PKG-INFO
5
7
  src/githubtakeout.egg-info/SOURCES.txt
6
8
  src/githubtakeout.egg-info/dependency_links.txt
@@ -0,0 +1,2 @@
1
+ githubtakeout
2
+ progress
@@ -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
+ )
File without changes
File without changes
File without changes