dump-github 0.1.8__py3-none-any.whl → 0.2.1__py3-none-any.whl

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.
dump_github/__init__.py CHANGED
@@ -1,2 +1,4 @@
1
- # from .lib import *
2
- from .main import main
1
+ from ._version import __version__
2
+ from .main import main
3
+
4
+ __all__ = ["__version__", "main"]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
dump_github/lib.py CHANGED
@@ -1,63 +1,345 @@
1
- import requests
2
-
3
-
4
- def _github_auth_headers(token):
5
- """GitHub REST API expects Authorization: Bearer <token> (PAT classic / fine-grained)."""
6
- if not token:
7
- return {}
8
- token = token.strip()
9
- if not token:
10
- return {}
11
- if token.lower().startswith(("bearer ", "token ", "basic ")):
12
- return {"Authorization": token}
13
- return {"Authorization": "Bearer {}".format(token)}
14
-
15
-
16
- def get_page_of_repos(username, page, token=""):
17
- token_header = _github_auth_headers(token)
18
-
19
- if token_header:
20
- url = "https://api.github.com/user/repos?per_page=100&page={}".format(page)
21
- headers = {
22
- "Accept": "application/vnd.github+json",
23
- "X-GitHub-Api-Version": "2022-11-28",
24
- }
25
- headers.update(token_header)
26
- response = requests.get(url, headers=headers)
27
- if response.status_code == 200:
28
- repos = response.json()
29
- return repos
30
- else:
31
- return []
32
- else:
33
- url = "https://api.github.com/users/{}/repos?per_page=100&page={}".format(
34
- username, page
35
- )
36
- headers = {
37
- "Accept": "application/vnd.github+json",
38
- "X-GitHub-Api-Version": "2022-11-28",
39
- }
40
- response = requests.get(url, headers=headers)
41
- if response.status_code == 200:
42
- repos = response.json()
43
- return repos
44
- else:
45
- return []
46
-
47
-
48
- def search_user_repos(username, token=""):
49
- page = 0
50
- res_repos = []
51
-
52
- while True:
53
- page += 1
54
- repos = get_page_of_repos(username, page, token)
55
- if len(repos) == 0:
56
- break
57
- res_repos += repos
58
-
59
- return res_repos
60
-
61
-
62
- # Backward-compatible alias for the historical typo
63
- serach_user_repos = search_user_repos
1
+ import re
2
+ import warnings
3
+ from urllib.parse import quote
4
+
5
+ import requests
6
+
7
+ GITHUB_API = "https://api.github.com"
8
+ GITHUB_WEB = "https://github.com"
9
+ PER_PAGE = 100
10
+ WEB_USER_AGENT = "dump-github (+https://github.com/Core2002/dump_github)"
11
+
12
+
13
+ class GitHubAPIError(RuntimeError):
14
+ """GitHub could not be read through the REST API (or the web fallback)."""
15
+
16
+ def __init__(self, status_code, message):
17
+ self.status_code = status_code
18
+ self.message = message
19
+ super().__init__(message)
20
+
21
+
22
+ def _github_auth_headers(token):
23
+ """GitHub REST API expects Authorization: Bearer <token> (PAT classic / fine-grained)."""
24
+ if not token:
25
+ return {}
26
+ token = token.strip()
27
+ if not token:
28
+ return {}
29
+ if token.lower().startswith(("bearer ", "token ", "basic ")):
30
+ return {"Authorization": token}
31
+ return {"Authorization": "Bearer {}".format(token)}
32
+
33
+
34
+ def _api_headers(token=""):
35
+ headers = {
36
+ "Accept": "application/vnd.github+json",
37
+ "X-GitHub-Api-Version": "2022-11-28",
38
+ }
39
+ headers.update(_github_auth_headers(token))
40
+ return headers
41
+
42
+
43
+ def _api_get(path, token=""):
44
+ """GET one GitHub REST API path and return the decoded JSON.
45
+
46
+ Raises GitHubAPIError instead of silently returning an empty result, so
47
+ callers can distinguish "no repositories" from "request failed".
48
+ """
49
+ try:
50
+ response = requests.get(
51
+ GITHUB_API + path, headers=_api_headers(token), timeout=30
52
+ )
53
+ except requests.RequestException as exc:
54
+ raise GitHubAPIError(None, "request failed: {}".format(exc)) from exc
55
+ if response.status_code != 200:
56
+ message = "HTTP {}".format(response.status_code)
57
+ try:
58
+ payload = response.json()
59
+ except ValueError:
60
+ payload = None
61
+ if isinstance(payload, dict) and payload.get("message"):
62
+ message = "{} (HTTP {})".format(payload["message"], response.status_code)
63
+ raise GitHubAPIError(response.status_code, message)
64
+ try:
65
+ return response.json()
66
+ except ValueError as exc:
67
+ raise GitHubAPIError(
68
+ response.status_code, "invalid JSON response from GitHub"
69
+ ) from exc
70
+
71
+
72
+ def get_authenticated_login(token=""):
73
+ """Return the login of the token owner, or None if there is no usable token."""
74
+ if not _github_auth_headers(token):
75
+ return None
76
+ try:
77
+ account = _api_get("/user", token)
78
+ except GitHubAPIError:
79
+ return None
80
+ if not isinstance(account, dict):
81
+ return None
82
+ return account.get("login") or None
83
+
84
+
85
+ def get_account_type(username, token=""):
86
+ """Return ``"Organization"``/``"User"`` for ``username``, or None if unknown."""
87
+ quoted = quote(username, safe="")
88
+ try:
89
+ account = _api_get("/users/{}".format(quoted), token)
90
+ except GitHubAPIError:
91
+ account = None
92
+ if isinstance(account, dict) and account.get("type"):
93
+ return account["type"]
94
+ # ``/users/{org}`` is not guaranteed to resolve for organisations, so fall
95
+ # back to the org endpoint, which unambiguously identifies one.
96
+ try:
97
+ org = _api_get("/orgs/{}".format(quoted), token)
98
+ except GitHubAPIError:
99
+ org = None
100
+ if isinstance(org, dict) and org.get("login"):
101
+ return "Organization"
102
+ return None
103
+
104
+
105
+ def _repos_path(username, page, authenticated_login, account_type):
106
+ """Build a listing API path that only contains repositories owned by ``username``.
107
+
108
+ ``GET /user/repos`` (used by an earlier version whenever a token was
109
+ supplied) lists every repository the token owner can see, which includes
110
+ repositories of the organisations the token owner is a member of, and it
111
+ ignores ``username`` entirely. Listing must therefore be scoped to the
112
+ requested account:
113
+
114
+ * organisation -> ``/orgs/{org}/repos?type=all``; only repositories owned
115
+ by that organisation, including private ones the token may access
116
+ * own user account -> ``/user/repos?affiliation=owner``; keeps private
117
+ repos, drops organisation/collaborator repos
118
+ * any other user account -> ``/users/{username}/repos?type=owner``
119
+ """
120
+ quoted = quote(username, safe="")
121
+ if (account_type or "").lower() == "organization":
122
+ return "/orgs/{}/repos?type=all&per_page={}&page={}".format(
123
+ quoted, PER_PAGE, page
124
+ )
125
+ own_account = bool(authenticated_login) and (
126
+ authenticated_login.lower() == username.lower()
127
+ )
128
+ if own_account:
129
+ return "/user/repos?affiliation=owner&per_page={}&page={}".format(
130
+ PER_PAGE, page
131
+ )
132
+ return "/users/{}/repos?type=owner&per_page={}&page={}".format(
133
+ quoted, PER_PAGE, page
134
+ )
135
+
136
+
137
+ def _is_owned_by(repo, username):
138
+ owner = repo.get("owner") or {}
139
+ login = owner.get("login") or ""
140
+ if login:
141
+ return login.lower() == username.lower()
142
+ full_name = repo.get("full_name") or ""
143
+ return full_name.split("/", 1)[0].lower() == username.lower()
144
+
145
+
146
+ def _fetch_repos_page(username, page, token="", authenticated_login=None,
147
+ account_type=None):
148
+ """Fetch one raw page of repositories owned by ``username``."""
149
+ path = _repos_path(username, page, authenticated_login, account_type)
150
+ repos = _api_get(path, token)
151
+ if not isinstance(repos, list):
152
+ return []
153
+ return [repo for repo in repos if isinstance(repo, dict)]
154
+
155
+
156
+ def get_page_of_repos(username, page, token="", authenticated_login=None,
157
+ account_type=None):
158
+ username = username.strip()
159
+ if account_type is None:
160
+ account_type = get_account_type(username, token)
161
+ if authenticated_login is None:
162
+ authenticated_login = get_authenticated_login(token)
163
+ repos = _fetch_repos_page(
164
+ username, page, token, authenticated_login, account_type
165
+ )
166
+ return [repo for repo in repos if _is_owned_by(repo, username)]
167
+
168
+
169
+ # --- public web fallback -------------------------------------------------
170
+ #
171
+ # api.github.com allows only 60 unauthenticated requests per hour *per IP*.
172
+ # Shared/NATed networks and CI runners exhaust that quickly, which used to make
173
+ # ``dump-github user`` silently return nothing. github.com's repository pages
174
+ # are not subject to that API quota, so they are used as a read-only fallback
175
+ # for public repositories when no token is configured.
176
+
177
+ _USER_REPO_TAG_RE = re.compile(r'<a\b[^>]*itemprop="name codeRepository"[^>]*>')
178
+ _WEB_REPO_HREF_RE = re.compile(r'href="/([^"/]+)/([^"/?#]+)"')
179
+ _WEB_NEXT_RE = re.compile(r'rel="next"')
180
+ _WEB_NON_REPO_PATHS = {
181
+ "followers",
182
+ "following",
183
+ "people",
184
+ "packages",
185
+ "projects",
186
+ "repositories",
187
+ "settings",
188
+ "sponsoring",
189
+ }
190
+
191
+
192
+ def _web_get(url):
193
+ try:
194
+ response = requests.get(
195
+ url, headers={"User-Agent": WEB_USER_AGENT}, timeout=30
196
+ )
197
+ except requests.RequestException as exc:
198
+ raise GitHubAPIError(None, "web request failed: {}".format(exc)) from exc
199
+ if response.status_code != 200:
200
+ raise GitHubAPIError(
201
+ response.status_code,
202
+ "github.com returned HTTP {}".format(response.status_code),
203
+ )
204
+ return response.text
205
+
206
+
207
+ def _web_repo(owner, name):
208
+ return {
209
+ "name": name,
210
+ "full_name": "{}/{}".format(owner, name),
211
+ "owner": {"login": owner},
212
+ "clone_url": "{}/{}/{}.git".format(GITHUB_WEB, owner, name),
213
+ # The default branch is not exposed by the web listing; zip downloads
214
+ # then use the HEAD archive instead.
215
+ }
216
+
217
+
218
+ def _parse_user_repos_page(html, username):
219
+ repos = []
220
+ seen = set()
221
+ for tag in _USER_REPO_TAG_RE.findall(html):
222
+ match = _WEB_REPO_HREF_RE.search(tag)
223
+ if not match:
224
+ continue
225
+ owner, name = match.group(1), match.group(2)
226
+ if owner.lower() != username.lower() or name in seen:
227
+ continue
228
+ seen.add(name)
229
+ repos.append(_web_repo(owner, name))
230
+ return repos
231
+
232
+
233
+ def _parse_org_repos_page(html, org):
234
+ repos = []
235
+ seen = set()
236
+ for owner, name in _WEB_REPO_HREF_RE.findall(html):
237
+ if owner.lower() != org.lower():
238
+ continue
239
+ if name.lower() in _WEB_NON_REPO_PATHS or name in seen:
240
+ continue
241
+ seen.add(name)
242
+ repos.append(_web_repo(owner, name))
243
+ return repos
244
+
245
+
246
+ def _web_page_url(username, page, is_org):
247
+ quoted = quote(username, safe="")
248
+ if is_org:
249
+ return "{}/orgs/{}/repositories?page={}".format(GITHUB_WEB, quoted, page)
250
+ return "{}/{}?tab=repositories&page={}".format(GITHUB_WEB, quoted, page)
251
+
252
+
253
+ def _parse_web_page(html, username, is_org):
254
+ if is_org:
255
+ return _parse_org_repos_page(html, username)
256
+ return _parse_user_repos_page(html, username)
257
+
258
+
259
+ def _search_via_web(username, account_type):
260
+ """List public repositories from github.com when the REST API is unavailable."""
261
+ is_org = (account_type or "").lower() == "organization"
262
+ first = _web_get(_web_page_url(username, 1, is_org))
263
+ repos = _parse_web_page(first, username, is_org)
264
+ if not repos and account_type is None:
265
+ # The account may be an organisation: its profile page redirects to
266
+ # github.com/{org} and does not contain the plain repository list.
267
+ try:
268
+ org_first = _web_get(_web_page_url(username, 1, True))
269
+ except GitHubAPIError as exc:
270
+ if exc.status_code == 404:
271
+ return []
272
+ raise
273
+ org_repos = _parse_org_repos_page(org_first, username)
274
+ if not org_repos:
275
+ return []
276
+ is_org = True
277
+ repos = org_repos
278
+ first = org_first
279
+ if not repos:
280
+ return []
281
+ page = 1
282
+ while _WEB_NEXT_RE.search(first):
283
+ page += 1
284
+ html = _web_get(_web_page_url(username, page, is_org))
285
+ page_repos = _parse_web_page(html, username, is_org)
286
+ if not page_repos:
287
+ break
288
+ repos.extend(page_repos)
289
+ first = html
290
+ return repos
291
+
292
+
293
+ def _search_via_api(username, token, authenticated_login, account_type):
294
+ page = 0
295
+ res_repos = []
296
+ while True:
297
+ page += 1
298
+ repos = _fetch_repos_page(
299
+ username, page, token, authenticated_login, account_type
300
+ )
301
+ if len(repos) == 0:
302
+ break
303
+ res_repos += repos
304
+ if len(repos) < PER_PAGE:
305
+ break
306
+ return res_repos
307
+
308
+
309
+ def search_user_repos(username, token=""):
310
+ username = username.strip()
311
+ authenticated_login = get_authenticated_login(token)
312
+ account_type = get_account_type(username, token)
313
+ try:
314
+ repos = _search_via_api(
315
+ username, token, authenticated_login, account_type
316
+ )
317
+ except GitHubAPIError as exc:
318
+ if token or exc.status_code == 404:
319
+ raise
320
+ warnings.warn(
321
+ "GitHub API request failed ({}); falling back to the public GitHub "
322
+ "web listing. Only public repositories can be listed without a "
323
+ "token.".format(exc),
324
+ RuntimeWarning,
325
+ stacklevel=2,
326
+ )
327
+ repos = _search_via_web(username, account_type)
328
+
329
+ # Never return repositories that belong to somebody else (e.g. a user repo
330
+ # returned for an organisation, or an organisation the token owner is in).
331
+ result = []
332
+ seen = set()
333
+ for repo in repos:
334
+ if not _is_owned_by(repo, username):
335
+ continue
336
+ key = (repo.get("full_name") or repo.get("name") or "").lower()
337
+ if key in seen:
338
+ continue
339
+ seen.add(key)
340
+ result.append(repo)
341
+ return result
342
+
343
+
344
+ # Backward-compatible alias for the historical typo
345
+ serach_user_repos = search_user_repos
dump_github/main.py CHANGED
@@ -1,109 +1,152 @@
1
- import argparse
2
- import os
3
- import subprocess
4
- from urllib.parse import quote
5
-
6
- from .lib import search_user_repos
7
-
8
- parser = argparse.ArgumentParser(
9
- prog="dump-github",
10
- description="Backup users github repo.",
11
- epilog="https://github.com/Core2002/dump_github",
12
- )
13
-
14
- parser.add_argument("username")
15
- parser.add_argument(
16
- "-d", "--download_zip", action="store_true", help="only download zip file"
17
- )
18
- parser.add_argument("-p", "--print", action="store_true", help="only print urls")
19
- parser.add_argument(
20
- "--token",
21
- action="store",
22
- default="",
23
- help="GitHub personal access token (or set GITHUB_TOKEN). Required for private repos and higher API rate limits.",
24
- )
25
- parser.add_argument(
26
- "--limit_size",
27
- type=int,
28
- default=100,
29
- help="limit size(MB) of download zip file, if 0 then no limit(default:100).",
30
- )
31
-
32
- args = parser.parse_args()
33
-
34
-
35
- def clone_repo(repo, token=""):
36
- name = repo["name"]
37
- url = repo["clone_url"]
38
- if token:
39
- url = url.replace(
40
- "https://",
41
- "https://x-access-token:{}@".format(quote(token, safe="")),
42
- 1,
43
- )
44
- print("Clone repo: {}".format(name))
45
- subprocess.run(["git", "clone", url], check=False)
46
-
47
-
48
- def download_zip(user_name, reop, token=""):
49
- ref = reop.get("default_branch") or "main"
50
- max_size = args.limit_size * 1000 * 1000
51
- file_name = "./{}_{}.zip".format(user_name, reop["name"])
52
- curl_cmd = [
53
- "curl",
54
- "-sS",
55
- "-L",
56
- "--connect-timeout",
57
- "5",
58
- "--retry",
59
- "3",
60
- "--retry-delay",
61
- "3",
62
- ]
63
- if max_size > 0:
64
- curl_cmd.extend(["--max-filesize", str(max_size)])
65
- if token:
66
- url = "https://api.github.com/repos/{}/{}/zipball/{}".format(
67
- user_name, reop["name"], ref
68
- )
69
- curl_cmd.extend(
70
- [
71
- "-H",
72
- "Authorization: Bearer {}".format(token),
73
- "-H",
74
- "Accept: application/vnd.github+json",
75
- "-o",
76
- file_name,
77
- url,
78
- ]
79
- )
80
- else:
81
- url = "https://github.com/{}/{}/archive/refs/heads/{}.zip".format(
82
- user_name, reop["name"], ref
83
- )
84
- curl_cmd.extend(["-o", file_name, url])
85
- print("Download {} : {}".format(reop["name"], url))
86
- subprocess.run(curl_cmd, check=False)
87
- if (
88
- max_size > 0
89
- and os.path.exists(file_name)
90
- and os.stat(file_name).st_size > max_size
91
- ):
92
- os.remove(file_name)
93
- print("Removed {} because it exceeds the limit size.".format(file_name))
94
-
95
-
96
- def main():
97
- token = (args.token or "").strip() or (os.environ.get("GITHUB_TOKEN") or "").strip()
98
- repos = search_user_repos(args.username, token)
99
- for repo in repos:
100
- if args.print == True:
101
- print(repo["clone_url"])
102
- elif args.download_zip == True:
103
- download_zip(args.username, repo, token)
104
- else:
105
- clone_repo(repo, token)
106
-
107
-
108
- if __name__ == "__main__":
109
- main()
1
+ import argparse
2
+ import os
3
+ import subprocess
4
+ import sys
5
+
6
+ from ._version import __version__
7
+ from .lib import GitHubAPIError, search_user_repos
8
+
9
+ # One-shot credential helper: git asks for credentials, the helper answers with
10
+ # the token from the environment. The token is therefore never part of the
11
+ # clone URL and never gets written to the cloned repository's .git/config.
12
+ GIT_CREDENTIAL_HELPER = (
13
+ '!f() { test "$1" = get || return 0; '
14
+ 'echo username=x-access-token; '
15
+ 'echo "password=$DUMP_GITHUB_TOKEN"; }; f'
16
+ )
17
+
18
+ parser = argparse.ArgumentParser(
19
+ prog="dump-github",
20
+ description="Backup users github repo.",
21
+ epilog="https://github.com/Core2002/dump_github",
22
+ )
23
+
24
+ parser.add_argument("username")
25
+ parser.add_argument(
26
+ "-d", "--download_zip", action="store_true", help="only download zip file"
27
+ )
28
+ parser.add_argument("-p", "--print", action="store_true", help="only print urls")
29
+ parser.add_argument(
30
+ "--version", action="version", version="%(prog)s {}".format(__version__)
31
+ )
32
+ parser.add_argument(
33
+ "--token",
34
+ action="store",
35
+ default="",
36
+ help="GitHub personal access token (or set GITHUB_TOKEN). Required for private repos and higher API rate limits.",
37
+ )
38
+ parser.add_argument(
39
+ "--limit_size",
40
+ type=int,
41
+ default=100,
42
+ help="limit size(MB) of download zip file, if 0 then no limit(default:100).",
43
+ )
44
+
45
+
46
+ def clone_repo(repo, token=""):
47
+ name = repo["name"]
48
+ url = repo["clone_url"]
49
+ cmd = ["git", "clone", url]
50
+ env = None
51
+ if token:
52
+ # Use a credential helper instead of https://x-access-token:TOKEN@...
53
+ # so the token does not leak into `ps` output or .git/config.
54
+ cmd = [
55
+ "git",
56
+ "-c",
57
+ "credential.helper=",
58
+ "-c",
59
+ "credential.helper={}".format(GIT_CREDENTIAL_HELPER),
60
+ "clone",
61
+ url,
62
+ ]
63
+ env = os.environ.copy()
64
+ env["DUMP_GITHUB_TOKEN"] = token
65
+ env["GIT_TERMINAL_PROMPT"] = "0"
66
+ print("Clone repo: {}".format(name))
67
+ subprocess.run(cmd, env=env, check=False)
68
+
69
+
70
+ def download_zip(user_name, reop, token="", limit_size=100):
71
+ ref = reop.get("default_branch")
72
+ # Always archive the repository the API actually listed. Using the
73
+ # positional username here would point at the wrong owner whenever the
74
+ # repo does not belong to that user.
75
+ full_name = reop.get("full_name") or "{}/{}".format(user_name, reop["name"])
76
+ max_size = limit_size * 1000 * 1000
77
+ file_name = "./{}.zip".format(full_name.replace("/", "_"))
78
+ curl_cmd = [
79
+ "curl",
80
+ "-sS",
81
+ "-L",
82
+ "--connect-timeout",
83
+ "5",
84
+ "--retry",
85
+ "3",
86
+ "--retry-delay",
87
+ "3",
88
+ ]
89
+ if max_size > 0:
90
+ curl_cmd.extend(["--max-filesize", str(max_size)])
91
+ if token:
92
+ url = "https://api.github.com/repos/{}/zipball".format(full_name)
93
+ if ref:
94
+ url += "/{}".format(ref)
95
+ curl_cmd.extend(
96
+ [
97
+ "-H",
98
+ "Authorization: Bearer {}".format(token),
99
+ "-H",
100
+ "Accept: application/vnd.github+json",
101
+ "-o",
102
+ file_name,
103
+ url,
104
+ ]
105
+ )
106
+ else:
107
+ # The web listing does not know the default branch; GitHub resolves
108
+ # HEAD to it.
109
+ if ref:
110
+ url = "https://github.com/{}/archive/refs/heads/{}.zip".format(full_name, ref)
111
+ else:
112
+ url = "https://github.com/{}/archive/HEAD.zip".format(full_name)
113
+ curl_cmd.extend(["-o", file_name, url])
114
+ print("Download {} : {}".format(reop["name"], url))
115
+ subprocess.run(curl_cmd, check=False)
116
+ if (
117
+ max_size > 0
118
+ and os.path.exists(file_name)
119
+ and os.stat(file_name).st_size > max_size
120
+ ):
121
+ os.remove(file_name)
122
+ print("Removed {} because it exceeds the limit size.".format(file_name))
123
+
124
+
125
+ def main(argv=None):
126
+ args = parser.parse_args(argv)
127
+ token = (args.token or "").strip() or (os.environ.get("GITHUB_TOKEN") or "").strip()
128
+ try:
129
+ repos = search_user_repos(args.username, token)
130
+ except GitHubAPIError as exc:
131
+ print(
132
+ "error: cannot list repositories for {}: {}".format(args.username, exc),
133
+ file=sys.stderr,
134
+ )
135
+ raise SystemExit(1)
136
+ if not repos:
137
+ print(
138
+ "No public repositories found for {}.".format(args.username),
139
+ file=sys.stderr,
140
+ )
141
+ return
142
+ for repo in repos:
143
+ if args.print:
144
+ print(repo["clone_url"])
145
+ elif args.download_zip:
146
+ download_zip(args.username, repo, token, args.limit_size)
147
+ else:
148
+ clone_repo(repo, token)
149
+
150
+
151
+ if __name__ == "__main__":
152
+ main()
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.4
2
+ Name: dump-github
3
+ Version: 0.2.1
4
+ Summary: Back up users' GitHub repositories in case they need them later.
5
+ Author-email: core2002 <core2002@aliyun.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Core2002/dump_github
8
+ Project-URL: Repository, https://github.com/Core2002/dump_github
9
+ Project-URL: Issues, https://github.com/Core2002/dump_github/issues
10
+ Project-URL: Changelog, https://github.com/Core2002/dump_github/blob/main/CHANGELOG.md
11
+ Keywords: github,backup,repositories,cli
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Version Control :: Git
23
+ Classifier: Topic :: System :: Archiving :: Backup
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: requests>=2.28
28
+ Provides-Extra: dev
29
+ Requires-Dist: build>=1.2; extra == "dev"
30
+ Requires-Dist: twine>=5; extra == "dev"
31
+ Dynamic: license-file
32
+
33
+ ### Github Dumper
34
+
35
+ [![CI](https://github.com/Core2002/dump_github/actions/workflows/ci.yml/badge.svg)](https://github.com/Core2002/dump_github/actions/workflows/ci.yml)
36
+ [![PyPI](https://img.shields.io/pypi/v/dump-github.svg)](https://pypi.org/project/dump-github/)
37
+
38
+ Github Dumper is a Python program designed to search for users' GitHub repositories and download the repositories. This tool can be used to back up users' GitHub repositories in case they need them later.
39
+
40
+ **Install (Python 3.10+):**
41
+
42
+ ```
43
+ pip install -U dump-github
44
+ ```
45
+
46
+ **Usage:**
47
+
48
+ ```
49
+ usage: dump-github [-h] [-d] [-p] [--version] [--token TOKEN]
50
+ [--limit_size LIMIT_SIZE]
51
+ username
52
+
53
+ Backup users github repo.
54
+
55
+ positional arguments:
56
+ username
57
+
58
+ options:
59
+ -h, --help show this help message and exit
60
+ -d, --download_zip only download zip file
61
+ -p, --print only print urls
62
+ --version show program's version number and exit
63
+ --token TOKEN GitHub personal access token (or set GITHUB_TOKEN).
64
+ Required for private repos and higher API rate limits.
65
+ --limit_size LIMIT_SIZE
66
+ limit size(MB) of download zip file, if 0 then no
67
+ limit(default:100).
68
+
69
+ https://github.com/Core2002/dump_github
70
+ ```
71
+
72
+ ### Authentication (token)
73
+
74
+ GitHub’s API requires a proper `Authorization: Bearer <token>` header. This tool accepts a **personal access token** in two ways (first match wins):
75
+
76
+ 1. **`--token`** on the command line
77
+ 2. **`GITHUB_TOKEN`** environment variable (if `--token` is empty)
78
+
79
+ **Why use a token?**
80
+
81
+ - **Private repositories**: Without a token, only public repos are listed and can be cloned or archived. With a token that has the right scopes, you can include private repos you are allowed to access (for example when backing up your own account).
82
+ - **Rate limits**: Authenticated API requests get a much higher limit than anonymous requests.
83
+
84
+ **Without a token**
85
+
86
+ Only public repositories can be listed and downloaded. The anonymous GitHub API allows
87
+ 60 requests per hour per IP, which shared/NATed networks and CI runners exhaust quickly;
88
+ if `api.github.com` is rate-limited or otherwise unavailable, the tool automatically
89
+ falls back to the public GitHub web listing
90
+ (`github.com/{user}?tab=repositories` / `github.com/orgs/{org}/repositories`), prints a
91
+ warning and keeps going. Private repositories still require a token.
92
+
93
+ **How the token is used**
94
+
95
+ - **Listing repos**: sent as `Bearer` on the REST API and scoped strictly to the requested account:
96
+ - **organisation** → `GET /orgs/{org}/repos?type=all` — only repositories owned by that organisation (private ones included when the token may access them);
97
+ - **your own user account** → `GET /user/repos?affiliation=owner` — private and public repos, without organisation/collaborator repos;
98
+ - **any other user** → `GET /users/{username}/repos?type=owner`.
99
+
100
+ Repositories whose owner is not the requested account are discarded as a final guard, so a user dump never includes organisation repositories and an organisation dump never includes user repositories.
101
+ - **Clone** (default mode): the token is handed to `git` through a one-shot credential helper (`credential.helper` reads `DUMP_GITHUB_TOKEN` from the child process environment), so the clone URL stays `https://github.com/...`. The token is never shown in `ps` output and never written to the cloned repository's `.git/config`; existing credential helpers are reset for that clone so a stale stored credential cannot shadow the token.
102
+ - **Zip download** (`-d`): uses the [repository archive (zipball)](https://docs.github.com/en/rest/repos/repos#download-a-repository-archive-zip) API with the same `Bearer` header (via `curl`), which works for private repos; without a token, public archive URLs are used instead.
103
+
104
+ Create a token under GitHub → **Settings** → **Developer settings** → **Personal access tokens**. For private repo backup, enable access to **repository contents** (classic PAT: `repo` scope; fine-grained: read access to the repositories you need).
105
+
106
+ **Examples**
107
+
108
+ ```bash
109
+ # Public repos only (no token; falls back to the web listing if the API is rate-limited)
110
+ dump-github Core2002 -p
111
+
112
+ # Public backup without any credentials
113
+ dump-github Core2002 -d
114
+
115
+ # Token on the command line (private + higher API limits)
116
+ dump-github username --token ghp_xxxxxxxxxxxx
117
+
118
+ # Back up an organisation (only that organisation's repositories)
119
+ dump-github some-org --token ghp_xxxxxxxxxxxx
120
+
121
+ # Same, via environment variable (recommended to avoid shell history)
122
+ export GITHUB_TOKEN=ghp_xxxxxxxxxxxx
123
+ dump-github Core2002 -d
124
+ ```
125
+
126
+ **Security note:** Do not commit tokens or paste them into shared logs. Prefer `GITHUB_TOKEN` in a local shell profile or a secret manager, and rotate the token if it leaks. Cloned repositories keep a clean remote URL (`https://github.com/...`), so `.git/config` does not contain the token.
127
+
128
+ ### Development
129
+
130
+ ```bash
131
+ python -m venv .venv
132
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
133
+ python -m pip install -e ".[dev]"
134
+ python -m unittest discover -s tests -v
135
+ python -m build # dist/: sdist + wheel
136
+ python -m twine check dist/*
137
+ ```
138
+
139
+ CI runs the test suite on Python 3.10, 3.12 and 3.13, and checks the build metadata with `twine check` (`.github/workflows/ci.yml`).
140
+
141
+ ### Releasing
142
+
143
+ 1. Bump `__version__` in `dump_github/_version.py` and move the changelog entries in `CHANGELOG.md` to the new version.
144
+ 2. Commit, tag and push:
145
+ ```bash
146
+ git tag v0.2.0
147
+ git push origin main v0.2.0
148
+ ```
149
+ 3. The `Release` workflow runs the tests, builds the sdist and wheel, checks them with `twine check` and publishes both to PyPI through **Trusted Publishing**.
150
+
151
+ One-time setup on PyPI: add a trusted publisher for the `Core2002/dump_github` repository and the `release.yml` workflow. No PyPI API token is stored in the repository. Manual fallback:
152
+
153
+ ```bash
154
+ python -m build
155
+ python -m twine upload dist/*
156
+ ```
@@ -0,0 +1,10 @@
1
+ dump_github/__init__.py,sha256=7bV8yfP0ii_gvNXKfDzFhBj2V5nC82mcJrlZqs9fRZ0,92
2
+ dump_github/_version.py,sha256=HfjVOrpTnmZ-xVFCYSVmX50EXaBQeJteUHG-PD6iQs8,22
3
+ dump_github/lib.py,sha256=elhVYzMSK2abVKPWqQUukQCCNdxdkCjGQPiW7kauGkA,11662
4
+ dump_github/main.py,sha256=unifRRUhyvu_2e7UMmoIxtRbVOANUGUUIQJbhaDQ_I8,4768
5
+ dump_github-0.2.1.dist-info/licenses/LICENSE,sha256=5tQa-lQLqvLhjTuELEk4bm70I5hcdjUeOn4qf3MK84E,1064
6
+ dump_github-0.2.1.dist-info/METADATA,sha256=-Ol1P5UNwJe6OjSiLD6LkFHQKbUO-jHYI5yEV-D7Ru8,7373
7
+ dump_github-0.2.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
8
+ dump_github-0.2.1.dist-info/entry_points.txt,sha256=2OAP2WnA1p54LWyCw3tHe44Pf4aR7Thsxf9uWKWfWl8,49
9
+ dump_github-0.2.1.dist-info/top_level.txt,sha256=6oWfdkP2U1AN9zL6AUIUTxe6hYIeo_Nf8M8SWBxElUw,12
10
+ dump_github-0.2.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (84.2.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2024 Core2002
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Core2002
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
@@ -1,92 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: dump_github
3
- Version: 0.1.8
4
- Summary: This tool can be used to back up users' GitHub repositories in case they need them later.
5
- Home-page: https://github.com/core2002/dump_github
6
- Author: core2002
7
- Author-email: core2002@aliyun.com
8
- License: MIT
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: License :: OSI Approved :: MIT License
11
- Classifier: Operating System :: OS Independent
12
- Description-Content-Type: text/markdown
13
- License-File: LICENSE
14
- Requires-Dist: argparse
15
- Requires-Dist: requests
16
- Dynamic: author
17
- Dynamic: author-email
18
- Dynamic: classifier
19
- Dynamic: description
20
- Dynamic: description-content-type
21
- Dynamic: home-page
22
- Dynamic: license
23
- Dynamic: license-file
24
- Dynamic: requires-dist
25
- Dynamic: summary
26
-
27
- ### Github Dumper
28
-
29
- Github Dumper is a Python program designed to search for users' GitHub repositories and download the repositories. This tool can be used to back up users' GitHub repositories in case they need them later.
30
-
31
- **Install:**
32
-
33
- ```
34
- pip install -U dump-github
35
- ```
36
-
37
- **Usage:**
38
-
39
- ```
40
- usage: dump-github [-h] [-d] [-p] [--token TOKEN] [--limit_size LIMIT_SIZE] username
41
-
42
- Backup users github repo.
43
-
44
- positional arguments:
45
- username
46
-
47
- options:
48
- -h, --help show this help message and exit
49
- -d, --download_zip only download zip file
50
- -p, --print only print urls
51
- --token TOKEN GitHub personal access token (or set GITHUB_TOKEN)
52
- --limit_size LIMIT_SIZE
53
- limit size(MB) of download zip file, if 0 then no limit(default:100).
54
-
55
- https://github.com/Core2002/dump_github
56
- ```
57
-
58
- ### Authentication (token)
59
-
60
- GitHub’s API requires a proper `Authorization: Bearer <token>` header. This tool accepts a **personal access token** in two ways (first match wins):
61
-
62
- 1. **`--token`** on the command line
63
- 2. **`GITHUB_TOKEN`** environment variable (if `--token` is empty)
64
-
65
- **Why use a token?**
66
-
67
- - **Private repositories**: Without a token, only public repos are listed and can be cloned or archived. With a token that has the right scopes, you can include private repos you are allowed to access (for example when backing up your own account).
68
- - **Rate limits**: Authenticated API requests get a much higher limit than anonymous requests.
69
-
70
- **How the token is used**
71
-
72
- - **Listing repos** (`GET /users/{username}/repos`): sent as `Bearer` on the REST API.
73
- - **Clone** (default mode): HTTPS clone URL is turned into `https://x-access-token:<token>@github.com/...` so `git clone` can access private repos.
74
- - **Zip download** (`-d`): uses the [repository archive (zipball)](https://docs.github.com/en/rest/repos/repos#download-a-repository-archive-zip) API with the same `Bearer` header (via `curl`), which works for private repos; without a token, public archive URLs are used instead.
75
-
76
- Create a token under GitHub → **Settings** → **Developer settings** → **Personal access tokens**. For private repo backup, enable access to **repository contents** (classic PAT: `repo` scope; fine-grained: read access to the repositories you need).
77
-
78
- **Examples**
79
-
80
- ```bash
81
- # Public repos only (no token)
82
- dump-github Core2002 -p
83
-
84
- # Token on the command line (private + higher API limits)
85
- dump-github username --token ghp_xxxxxxxxxxxx
86
-
87
- # Same, via environment variable (recommended to avoid shell history)
88
- export GITHUB_TOKEN=ghp_xxxxxxxxxxxx
89
- dump-github Core2002 -d
90
- ```
91
-
92
- **Security note:** Do not commit tokens or paste them into shared logs. Prefer `GITHUB_TOKEN` in a local shell profile or a secret manager, and rotate the token if it leaks.
@@ -1,9 +0,0 @@
1
- dump_github/__init__.py,sha256=5dzWctgs4c0g7raC2Kzas3wck0zHvXifUnSxy-dCPjg,44
2
- dump_github/lib.py,sha256=vEpwlJBrRr-g34spJPZmhRC9bdElngaaHCCxCQ86M1A,1843
3
- dump_github/main.py,sha256=H8QADAbNNbs_zIx5Hk6MzAFLgFfMA7bT06fqRpuyInQ,3150
4
- dump_github-0.1.8.dist-info/licenses/LICENSE,sha256=ac2hzJN6sGcBrmJXe7CkvzfaYdM5sEo8Ny1BWHML1yk,1084
5
- dump_github-0.1.8.dist-info/METADATA,sha256=h8x2qKi_OzFpSSunyfHGpSolUrKJGJJZl-YkWDzgfUY,3606
6
- dump_github-0.1.8.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
7
- dump_github-0.1.8.dist-info/entry_points.txt,sha256=2OAP2WnA1p54LWyCw3tHe44Pf4aR7Thsxf9uWKWfWl8,49
8
- dump_github-0.1.8.dist-info/top_level.txt,sha256=6oWfdkP2U1AN9zL6AUIUTxe6hYIeo_Nf8M8SWBxElUw,12
9
- dump_github-0.1.8.dist-info/RECORD,,