dump-github 0.2.0__tar.gz → 0.2.1__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.
@@ -5,6 +5,26 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
+ ## [0.2.1] - 2026-11-12
9
+
10
+ ### Fixed
11
+
12
+ - Non-token mode silently returned no repositories when the anonymous GitHub API
13
+ hit its 60 requests/hour/IP rate limit (common on shared/NAT/CI networks).
14
+ Listing now falls back to the public GitHub web repository pages
15
+ (`github.com/{user}?tab=repositories`, `github.com/orgs/{org}/repositories`)
16
+ and prints a warning, so public backups work again.
17
+ - GitHub API failures are no longer swallowed: the CLI reports the error and
18
+ exits with status 1 instead of exiting 0 with no output.
19
+ - Zip downloads no longer assume `main` when the default branch is unknown
20
+ (web fallback); the `HEAD` archive is used, and token mode omits the ref so
21
+ GitHub resolves the default branch.
22
+
23
+ ### Tests
24
+
25
+ - 27 tests, including the API rate-limit fallback, pagination, organisation
26
+ detection via the web listing, and the `HEAD` archive path.
27
+
8
28
  ## [0.2.0] - 2026-11-12
9
29
 
10
30
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dump-github
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Back up users' GitHub repositories in case they need them later.
5
5
  Author-email: core2002 <core2002@aliyun.com>
6
6
  License-Expression: MIT
@@ -81,6 +81,15 @@ GitHub’s API requires a proper `Authorization: Bearer <token>` header. This to
81
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
82
  - **Rate limits**: Authenticated API requests get a much higher limit than anonymous requests.
83
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
+
84
93
  **How the token is used**
85
94
 
86
95
  - **Listing repos**: sent as `Bearer` on the REST API and scoped strictly to the requested account:
@@ -97,9 +106,12 @@ Create a token under GitHub → **Settings** → **Developer settings** → **Pe
97
106
  **Examples**
98
107
 
99
108
  ```bash
100
- # Public repos only (no token)
109
+ # Public repos only (no token; falls back to the web listing if the API is rate-limited)
101
110
  dump-github Core2002 -p
102
111
 
112
+ # Public backup without any credentials
113
+ dump-github Core2002 -d
114
+
103
115
  # Token on the command line (private + higher API limits)
104
116
  dump-github username --token ghp_xxxxxxxxxxxx
105
117
 
@@ -49,6 +49,15 @@ GitHub’s API requires a proper `Authorization: Bearer <token>` header. This to
49
49
  - **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).
50
50
  - **Rate limits**: Authenticated API requests get a much higher limit than anonymous requests.
51
51
 
52
+ **Without a token**
53
+
54
+ Only public repositories can be listed and downloaded. The anonymous GitHub API allows
55
+ 60 requests per hour per IP, which shared/NATed networks and CI runners exhaust quickly;
56
+ if `api.github.com` is rate-limited or otherwise unavailable, the tool automatically
57
+ falls back to the public GitHub web listing
58
+ (`github.com/{user}?tab=repositories` / `github.com/orgs/{org}/repositories`), prints a
59
+ warning and keeps going. Private repositories still require a token.
60
+
52
61
  **How the token is used**
53
62
 
54
63
  - **Listing repos**: sent as `Bearer` on the REST API and scoped strictly to the requested account:
@@ -65,9 +74,12 @@ Create a token under GitHub → **Settings** → **Developer settings** → **Pe
65
74
  **Examples**
66
75
 
67
76
  ```bash
68
- # Public repos only (no token)
77
+ # Public repos only (no token; falls back to the web listing if the API is rate-limited)
69
78
  dump-github Core2002 -p
70
79
 
80
+ # Public backup without any credentials
81
+ dump-github Core2002 -d
82
+
71
83
  # Token on the command line (private + higher API limits)
72
84
  dump-github username --token ghp_xxxxxxxxxxxx
73
85
 
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
@@ -0,0 +1,345 @@
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
@@ -1,9 +1,10 @@
1
1
  import argparse
2
2
  import os
3
3
  import subprocess
4
+ import sys
4
5
 
5
6
  from ._version import __version__
6
- from .lib import search_user_repos
7
+ from .lib import GitHubAPIError, search_user_repos
7
8
 
8
9
  # One-shot credential helper: git asks for credentials, the helper answers with
9
10
  # the token from the environment. The token is therefore never part of the
@@ -67,7 +68,7 @@ def clone_repo(repo, token=""):
67
68
 
68
69
 
69
70
  def download_zip(user_name, reop, token="", limit_size=100):
70
- ref = reop.get("default_branch") or "main"
71
+ ref = reop.get("default_branch")
71
72
  # Always archive the repository the API actually listed. Using the
72
73
  # positional username here would point at the wrong owner whenever the
73
74
  # repo does not belong to that user.
@@ -88,7 +89,9 @@ def download_zip(user_name, reop, token="", limit_size=100):
88
89
  if max_size > 0:
89
90
  curl_cmd.extend(["--max-filesize", str(max_size)])
90
91
  if token:
91
- url = "https://api.github.com/repos/{}/zipball/{}".format(full_name, ref)
92
+ url = "https://api.github.com/repos/{}/zipball".format(full_name)
93
+ if ref:
94
+ url += "/{}".format(ref)
92
95
  curl_cmd.extend(
93
96
  [
94
97
  "-H",
@@ -101,7 +104,12 @@ def download_zip(user_name, reop, token="", limit_size=100):
101
104
  ]
102
105
  )
103
106
  else:
104
- url = "https://github.com/{}/archive/refs/heads/{}.zip".format(full_name, ref)
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)
105
113
  curl_cmd.extend(["-o", file_name, url])
106
114
  print("Download {} : {}".format(reop["name"], url))
107
115
  subprocess.run(curl_cmd, check=False)
@@ -117,7 +125,20 @@ def download_zip(user_name, reop, token="", limit_size=100):
117
125
  def main(argv=None):
118
126
  args = parser.parse_args(argv)
119
127
  token = (args.token or "").strip() or (os.environ.get("GITHUB_TOKEN") or "").strip()
120
- repos = search_user_repos(args.username, token)
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
121
142
  for repo in repos:
122
143
  if args.print:
123
144
  print(repo["clone_url"])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dump-github
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Back up users' GitHub repositories in case they need them later.
5
5
  Author-email: core2002 <core2002@aliyun.com>
6
6
  License-Expression: MIT
@@ -81,6 +81,15 @@ GitHub’s API requires a proper `Authorization: Bearer <token>` header. This to
81
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
82
  - **Rate limits**: Authenticated API requests get a much higher limit than anonymous requests.
83
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
+
84
93
  **How the token is used**
85
94
 
86
95
  - **Listing repos**: sent as `Bearer` on the REST API and scoped strictly to the requested account:
@@ -97,9 +106,12 @@ Create a token under GitHub → **Settings** → **Developer settings** → **Pe
97
106
  **Examples**
98
107
 
99
108
  ```bash
100
- # Public repos only (no token)
109
+ # Public repos only (no token; falls back to the web listing if the API is rate-limited)
101
110
  dump-github Core2002 -p
102
111
 
112
+ # Public backup without any credentials
113
+ dump-github Core2002 -d
114
+
103
115
  # Token on the command line (private + higher API limits)
104
116
  dump-github username --token ghp_xxxxxxxxxxxx
105
117
 
@@ -1,3 +1,4 @@
1
+ import json
1
2
  import os
2
3
  import sys
3
4
  import unittest
@@ -12,6 +13,7 @@ class FakeResponse:
12
13
  def __init__(self, status_code, payload):
13
14
  self.status_code = status_code
14
15
  self._payload = payload
16
+ self.text = payload if isinstance(payload, str) else json.dumps(payload)
15
17
 
16
18
  def json(self):
17
19
  return self._payload
@@ -26,6 +28,37 @@ def make_repo(owner, name):
26
28
  }
27
29
 
28
30
 
31
+ def url_matches(url, prefix):
32
+ return (
33
+ url == prefix
34
+ or url.startswith(prefix + "?")
35
+ or url.startswith(prefix + "/")
36
+ )
37
+
38
+
39
+ def user_repos_html(names, next_page=False):
40
+ body = "".join(
41
+ '<a href="/alice/{}" itemprop="name codeRepository" >{}</a>'.format(
42
+ name, name
43
+ )
44
+ for name in names
45
+ )
46
+ if next_page:
47
+ body += '<a rel="next" href="/alice?tab=repositories&amp;page=2">Next</a>'
48
+ return body
49
+
50
+
51
+ def org_repos_html(org, names, next_page=False):
52
+ body = "".join(
53
+ '<a href="/{}/{}">{}</a>'.format(org, name, name) for name in names
54
+ )
55
+ if next_page:
56
+ body += '<a rel="next" href="/orgs/{}/repositories?page=2">Next</a>'.format(
57
+ org
58
+ )
59
+ return body
60
+
61
+
29
62
  class ListingTests(unittest.TestCase):
30
63
  def setUp(self):
31
64
  self.calls = []
@@ -290,5 +323,132 @@ class ListingTests(unittest.TestCase):
290
323
  self.assertEqual([repo["name"] for repo in repos], ["public-repo"])
291
324
 
292
325
 
326
+ class WebFallbackTests(unittest.TestCase):
327
+ def setUp(self):
328
+ self.calls = []
329
+
330
+ def fake_get(self, web):
331
+ def get(url, headers=None, timeout=None):
332
+ self.calls.append((url, headers or {}))
333
+ if url.startswith("https://api.github.com"):
334
+ return FakeResponse(403, {"message": "API rate limit exceeded"})
335
+ return web(url)
336
+
337
+ return get
338
+
339
+ def urls(self):
340
+ return [url for url, _ in self.calls]
341
+
342
+ def test_api_403_falls_back_to_web_listing_for_user(self):
343
+ def web(url):
344
+ if url.startswith("https://github.com/alice?tab=repositories"):
345
+ return FakeResponse(200, user_repos_html(["one", "two"]))
346
+ return FakeResponse(404, {"message": "Not Found"})
347
+
348
+ with mock.patch.object(lib.requests, "get", self.fake_get(web)):
349
+ with self.assertWarns(RuntimeWarning) as caught:
350
+ repos = lib.search_user_repos("alice")
351
+
352
+ self.assertEqual([repo["name"] for repo in repos], ["one", "two"])
353
+ self.assertIn("rate limit", str(caught.warning))
354
+ self.assertIn(
355
+ "https://github.com/alice?tab=repositories&page=1", self.urls()
356
+ )
357
+ self.assertTrue(
358
+ all("Authorization" not in headers for _, headers in self.calls)
359
+ )
360
+ self.assertEqual(
361
+ repos[0]["clone_url"], "https://github.com/alice/one.git"
362
+ )
363
+ self.assertNotIn("default_branch", repos[0])
364
+
365
+ def test_web_fallback_paginates_until_no_next_link(self):
366
+ first_page = ["repo-{}".format(i) for i in range(30)]
367
+ second_page = ["last-a", "last-b"]
368
+
369
+ def web(url):
370
+ if "page=1" in url:
371
+ return FakeResponse(200, user_repos_html(first_page, next_page=True))
372
+ if "page=2" in url:
373
+ return FakeResponse(200, user_repos_html(second_page))
374
+ return FakeResponse(404, {"message": "Not Found"})
375
+
376
+ with mock.patch.object(lib.requests, "get", self.fake_get(web)):
377
+ with self.assertWarns(RuntimeWarning):
378
+ repos = lib.search_user_repos("alice")
379
+
380
+ self.assertEqual(len(repos), 32)
381
+ self.assertEqual(repos[-1]["name"], "last-b")
382
+ self.assertFalse(any("page=3" in url for url in self.urls()))
383
+
384
+ def test_web_fallback_detects_organisation(self):
385
+ def web(url):
386
+ if url.startswith("https://github.com/acme-org?tab=repositories"):
387
+ return FakeResponse(200, "<html>organisation profile</html>")
388
+ if url.startswith("https://github.com/orgs/acme-org/repositories"):
389
+ return FakeResponse(200, org_repos_html("acme-org", ["org-a", "org-b"]))
390
+ return FakeResponse(404, {"message": "Not Found"})
391
+
392
+ with mock.patch.object(lib.requests, "get", self.fake_get(web)):
393
+ with self.assertWarns(RuntimeWarning):
394
+ repos = lib.search_user_repos("acme-org")
395
+
396
+ self.assertEqual([repo["name"] for repo in repos], ["org-a", "org-b"])
397
+ self.assertIn(
398
+ "https://github.com/orgs/acme-org/repositories?page=1", self.urls()
399
+ )
400
+ self.assertEqual(
401
+ repos[0]["full_name"], "acme-org/org-a"
402
+ )
403
+
404
+ def test_web_fallback_returns_empty_for_missing_account(self):
405
+ def web(url):
406
+ if url.startswith("https://github.com/nobody?tab=repositories"):
407
+ return FakeResponse(200, "<html></html>")
408
+ return FakeResponse(404, {"message": "Not Found"})
409
+
410
+ with mock.patch.object(lib.requests, "get", self.fake_get(web)):
411
+ with self.assertWarns(RuntimeWarning):
412
+ repos = lib.search_user_repos("nobody")
413
+
414
+ self.assertEqual(repos, [])
415
+
416
+ def test_token_mode_403_raises_and_does_not_scrape(self):
417
+ def fake_get(url, headers=None, timeout=None):
418
+ self.calls.append((url, headers or {}))
419
+ if url == "https://api.github.com/user":
420
+ return FakeResponse(200, {"login": "bob"})
421
+ if url == "https://api.github.com/users/alice":
422
+ return FakeResponse(200, {"login": "alice", "type": "User"})
423
+ if url.startswith("https://api.github.com/users/alice/repos"):
424
+ return FakeResponse(403, {"message": "API rate limit exceeded"})
425
+ return FakeResponse(200, user_repos_html(["should-not-be-used"]))
426
+
427
+ with mock.patch.object(lib.requests, "get", fake_get):
428
+ with self.assertRaises(lib.GitHubAPIError) as caught:
429
+ lib.search_user_repos("alice", token="ghp_test")
430
+
431
+ self.assertEqual(caught.exception.status_code, 403)
432
+ self.assertFalse(
433
+ any(url.startswith("https://github.com/") for url in self.urls())
434
+ )
435
+
436
+ def test_api_404_is_reported_instead_of_scraping(self):
437
+ def fake_get(url, headers=None, timeout=None):
438
+ self.calls.append((url, headers or {}))
439
+ if url.startswith("https://api.github.com"):
440
+ return FakeResponse(404, {"message": "Not Found"})
441
+ return FakeResponse(200, user_repos_html(["should-not-be-used"]))
442
+
443
+ with mock.patch.object(lib.requests, "get", fake_get):
444
+ with self.assertRaises(lib.GitHubAPIError) as caught:
445
+ lib.search_user_repos("ghost")
446
+
447
+ self.assertEqual(caught.exception.status_code, 404)
448
+ self.assertFalse(
449
+ any(url.startswith("https://github.com/") for url in self.urls())
450
+ )
451
+
452
+
293
453
  if __name__ == "__main__":
294
454
  unittest.main()
@@ -1,4 +1,6 @@
1
+ import contextlib
1
2
  import importlib
3
+ import io
2
4
  import os
3
5
  import sys
4
6
  import unittest
@@ -100,5 +102,75 @@ class CloneTests(unittest.TestCase):
100
102
  self.assertNotIn("DUMP_GITHUB_TOKEN", result.stdout)
101
103
 
102
104
 
105
+ class CliBehaviourTests(unittest.TestCase):
106
+ def setUp(self):
107
+ self.calls = []
108
+
109
+ def fake_run(self, cmd, **kwargs):
110
+ self.calls.append((cmd, kwargs))
111
+ return mock.Mock(returncode=0)
112
+
113
+ def test_zip_without_token_uses_head_when_branch_unknown(self):
114
+ repo = {"name": "repo", "full_name": "alice/repo"}
115
+ with mock.patch.object(main.subprocess, "run", self.fake_run):
116
+ main.download_zip("alice", repo, limit_size=0)
117
+
118
+ cmd = self.calls[0][0]
119
+ self.assertEqual(
120
+ cmd[-1], "https://github.com/alice/repo/archive/HEAD.zip"
121
+ )
122
+
123
+ def test_zip_with_token_and_known_branch_uses_zipball_ref(self):
124
+ repo = {
125
+ "name": "repo",
126
+ "full_name": "alice/repo",
127
+ "default_branch": "main",
128
+ }
129
+ with mock.patch.object(main.subprocess, "run", self.fake_run):
130
+ main.download_zip("alice", repo, token="ghp_test", limit_size=0)
131
+
132
+ cmd = self.calls[0][0]
133
+ self.assertIn(
134
+ "https://api.github.com/repos/alice/repo/zipball/main", cmd
135
+ )
136
+ self.assertIn("Authorization: Bearer ghp_test", cmd)
137
+
138
+ def test_zip_with_token_and_unknown_branch_omits_ref(self):
139
+ repo = {"name": "repo", "full_name": "alice/repo"}
140
+ with mock.patch.object(main.subprocess, "run", self.fake_run):
141
+ main.download_zip("alice", repo, token="ghp_test", limit_size=0)
142
+
143
+ cmd = self.calls[0][0]
144
+ self.assertIn("https://api.github.com/repos/alice/repo/zipball", cmd)
145
+ self.assertFalse(
146
+ any("/zipball/" in part for part in cmd)
147
+ )
148
+
149
+ def test_listing_error_exits_nonzero_with_message(self):
150
+ err = io.StringIO()
151
+ with mock.patch.object(
152
+ main,
153
+ "search_user_repos",
154
+ side_effect=main.GitHubAPIError(
155
+ 403, "API rate limit exceeded (HTTP 403)"
156
+ ),
157
+ ):
158
+ with self.assertRaises(SystemExit) as caught:
159
+ with contextlib.redirect_stderr(err):
160
+ main.main(["alice"])
161
+
162
+ self.assertEqual(caught.exception.code, 1)
163
+ self.assertIn("alice", err.getvalue())
164
+ self.assertIn("rate limit", err.getvalue())
165
+
166
+ def test_empty_listing_reports_message(self):
167
+ err = io.StringIO()
168
+ with mock.patch.object(main, "search_user_repos", return_value=[]):
169
+ with contextlib.redirect_stderr(err):
170
+ main.main(["alice"])
171
+
172
+ self.assertIn("No public repositories found", err.getvalue())
173
+
174
+
103
175
  if __name__ == "__main__":
104
176
  unittest.main()
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
@@ -1,158 +0,0 @@
1
- from urllib.parse import quote
2
-
3
- import requests
4
-
5
- GITHUB_API = "https://api.github.com"
6
- PER_PAGE = 100
7
-
8
-
9
- def _github_auth_headers(token):
10
- """GitHub REST API expects Authorization: Bearer <token> (PAT classic / fine-grained)."""
11
- if not token:
12
- return {}
13
- token = token.strip()
14
- if not token:
15
- return {}
16
- if token.lower().startswith(("bearer ", "token ", "basic ")):
17
- return {"Authorization": token}
18
- return {"Authorization": "Bearer {}".format(token)}
19
-
20
-
21
- def _api_headers(token=""):
22
- headers = {
23
- "Accept": "application/vnd.github+json",
24
- "X-GitHub-Api-Version": "2022-11-28",
25
- }
26
- headers.update(_github_auth_headers(token))
27
- return headers
28
-
29
-
30
- def _api_get(path, token=""):
31
- """GET one GitHub REST API path and return the decoded JSON, or None on failure."""
32
- try:
33
- response = requests.get(
34
- GITHUB_API + path, headers=_api_headers(token), timeout=30
35
- )
36
- except requests.RequestException:
37
- return None
38
- if response.status_code != 200:
39
- return None
40
- try:
41
- return response.json()
42
- except ValueError:
43
- return None
44
-
45
-
46
- def get_authenticated_login(token=""):
47
- """Return the login of the token owner, or None if there is no usable token."""
48
- if not _github_auth_headers(token):
49
- return None
50
- account = _api_get("/user", token)
51
- if not isinstance(account, dict):
52
- return None
53
- return account.get("login") or None
54
-
55
-
56
- def get_account_type(username, token=""):
57
- """Return ``"Organization"``/``"User"`` for ``username``, or None if unknown."""
58
- quoted = quote(username, safe="")
59
- account = _api_get("/users/{}".format(quoted), token)
60
- if isinstance(account, dict) and account.get("type"):
61
- return account["type"]
62
- # ``/users/{org}`` is not guaranteed to resolve for organisations, so fall
63
- # back to the org endpoint, which unambiguously identifies one.
64
- org = _api_get("/orgs/{}".format(quoted), token)
65
- if isinstance(org, dict) and org.get("login"):
66
- return "Organization"
67
- return None
68
-
69
-
70
- def _repos_path(username, page, authenticated_login, account_type):
71
- """Build a listing API path that only contains repositories owned by ``username``.
72
-
73
- ``GET /user/repos`` (used by an earlier version whenever a token was
74
- supplied) lists every repository the token owner can see, which includes
75
- repositories of the organisations the token owner is a member of, and it
76
- ignores ``username`` entirely. Listing must therefore be scoped to the
77
- requested account:
78
-
79
- * organisation -> ``/orgs/{org}/repos?type=all``; only repositories owned
80
- by that organisation, including private ones the token may access
81
- * own user account -> ``/user/repos?affiliation=owner``; keeps private
82
- repos, drops organisation/collaborator repos
83
- * any other user account -> ``/users/{username}/repos?type=owner``
84
- """
85
- quoted = quote(username, safe="")
86
- if (account_type or "").lower() == "organization":
87
- return "/orgs/{}/repos?type=all&per_page={}&page={}".format(
88
- quoted, PER_PAGE, page
89
- )
90
- own_account = bool(authenticated_login) and (
91
- authenticated_login.lower() == username.lower()
92
- )
93
- if own_account:
94
- return "/user/repos?affiliation=owner&per_page={}&page={}".format(
95
- PER_PAGE, page
96
- )
97
- return "/users/{}/repos?type=owner&per_page={}&page={}".format(
98
- quoted, PER_PAGE, page
99
- )
100
-
101
-
102
- def _is_owned_by(repo, username):
103
- owner = repo.get("owner") or {}
104
- login = owner.get("login") or ""
105
- if login:
106
- return login.lower() == username.lower()
107
- full_name = repo.get("full_name") or ""
108
- return full_name.split("/", 1)[0].lower() == username.lower()
109
-
110
-
111
- def _fetch_repos_page(username, page, token="", authenticated_login=None,
112
- account_type=None):
113
- """Fetch one raw page of repositories owned by ``username``."""
114
- path = _repos_path(username, page, authenticated_login, account_type)
115
- repos = _api_get(path, token)
116
- if not isinstance(repos, list):
117
- return []
118
- return [repo for repo in repos if isinstance(repo, dict)]
119
-
120
-
121
- def get_page_of_repos(username, page, token="", authenticated_login=None,
122
- account_type=None):
123
- username = username.strip()
124
- if account_type is None:
125
- account_type = get_account_type(username, token)
126
- if authenticated_login is None:
127
- authenticated_login = get_authenticated_login(token)
128
- repos = _fetch_repos_page(
129
- username, page, token, authenticated_login, account_type
130
- )
131
- return [repo for repo in repos if _is_owned_by(repo, username)]
132
-
133
-
134
- def search_user_repos(username, token=""):
135
- username = username.strip()
136
- authenticated_login = get_authenticated_login(token)
137
- account_type = get_account_type(username, token)
138
- page = 0
139
- res_repos = []
140
-
141
- while True:
142
- page += 1
143
- repos = _fetch_repos_page(
144
- username, page, token, authenticated_login, account_type
145
- )
146
- if len(repos) == 0:
147
- break
148
- res_repos += repos
149
- if len(repos) < PER_PAGE:
150
- break
151
-
152
- # Never return repositories that belong to somebody else (e.g. a user repo
153
- # returned for an organisation, or an organisation the token owner is in).
154
- return [repo for repo in res_repos if _is_owned_by(repo, username)]
155
-
156
-
157
- # Backward-compatible alias for the historical typo
158
- serach_user_repos = search_user_repos
File without changes
File without changes
File without changes
File without changes