github-backup 0.61.3__tar.gz → 0.61.5__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.
- {github_backup-0.61.3 → github_backup-0.61.5}/CHANGES.rst +70 -1
- {github_backup-0.61.3/github_backup.egg-info → github_backup-0.61.5}/PKG-INFO +1 -1
- github_backup-0.61.5/github_backup/__init__.py +1 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/github_backup/github_backup.py +79 -48
- {github_backup-0.61.3 → github_backup-0.61.5/github_backup.egg-info}/PKG-INFO +1 -1
- {github_backup-0.61.3 → github_backup-0.61.5}/release-requirements.txt +1 -1
- github_backup-0.61.5/tests/test_http_451.py +203 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_retrieve_data.py +22 -0
- github_backup-0.61.3/github_backup/__init__.py +0 -1
- github_backup-0.61.3/tests/test_http_451.py +0 -91
- {github_backup-0.61.3 → github_backup-0.61.5}/LICENSE.txt +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/MANIFEST.in +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/README +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/README.rst +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/github_backup/__main__.py +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/github_backup/cli.py +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/github_backup.egg-info/SOURCES.txt +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/github_backup.egg-info/dependency_links.txt +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/github_backup.egg-info/entry_points.txt +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/github_backup.egg-info/top_level.txt +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/github_backup.egg-info/zip-safe +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/requirements.txt +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/setup.cfg +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/setup.py +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_all_starred.py +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_attachments.py +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_case_sensitivity.py +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_json_dump_if_changed.py +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_pagination.py +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_skip_assets_on.py +0 -0
- {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_starred_skip_size_over.py +0 -0
|
@@ -1,9 +1,78 @@
|
|
|
1
1
|
Changelog
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
-
0.61.
|
|
4
|
+
0.61.5 (2026-02-18)
|
|
5
5
|
-------------------
|
|
6
6
|
------------------------
|
|
7
|
+
- Fix empty repository crash due to None timestamp comparison (#489)
|
|
8
|
+
[Rodos]
|
|
9
|
+
|
|
10
|
+
Empty repositories have None for pushed_at/updated_at, causing a
|
|
11
|
+
TypeError when compared to the last_update string. Use .get() with
|
|
12
|
+
truthiness check to skip None timestamps in incremental tracking.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
0.61.4 (2026-02-16)
|
|
16
|
+
-------------------
|
|
17
|
+
- Fix HTTP 451 DMCA and 403 TOS handling regression (#487) [Rodos]
|
|
18
|
+
|
|
19
|
+
The DMCA handling added in PR #454 had a bug: make_request_with_retry()
|
|
20
|
+
raises HTTPError before retrieve_data() could check the status code via
|
|
21
|
+
getcode(), making the case 451 handler dead code. This also affected
|
|
22
|
+
HTTP 403 TOS violations (e.g. jumoog/MagiskOnWSA).
|
|
23
|
+
|
|
24
|
+
Fix by catching HTTPError in retrieve_data() and converting 451 and
|
|
25
|
+
blocked 403 responses (identified by "block" key in response body) to
|
|
26
|
+
RepositoryUnavailableError. Non-block 403s (permissions, scopes) still
|
|
27
|
+
propagate as HTTPError. Also handle RepositoryUnavailableError in
|
|
28
|
+
retrieve_repositories() for the --repository case.
|
|
29
|
+
|
|
30
|
+
Rewrote tests to mock urlopen (not make_request_with_retry) to exercise
|
|
31
|
+
the real code path that was previously untested.
|
|
32
|
+
|
|
33
|
+
Closes #487
|
|
34
|
+
- Chore(deps): bump setuptools in the python-packages group.
|
|
35
|
+
[dependabot[bot]]
|
|
36
|
+
|
|
37
|
+
Bumps the python-packages group with 1 update: [setuptools](https://github.com/pypa/setuptools).
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
Updates `setuptools` from 80.10.2 to 82.0.0
|
|
41
|
+
- [Release notes](https://github.com/pypa/setuptools/releases)
|
|
42
|
+
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
|
|
43
|
+
- [Commits](https://github.com/pypa/setuptools/compare/v80.10.2...v82.0.0)
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
updated-dependencies:
|
|
47
|
+
- dependency-name: setuptools
|
|
48
|
+
dependency-version: 82.0.0
|
|
49
|
+
dependency-type: direct:production
|
|
50
|
+
update-type: version-update:semver-major
|
|
51
|
+
dependency-group: python-packages
|
|
52
|
+
...
|
|
53
|
+
- Chore(deps): bump setuptools in the python-packages group.
|
|
54
|
+
[dependabot[bot]]
|
|
55
|
+
|
|
56
|
+
Bumps the python-packages group with 1 update: [setuptools](https://github.com/pypa/setuptools).
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
Updates `setuptools` from 80.10.1 to 80.10.2
|
|
60
|
+
- [Release notes](https://github.com/pypa/setuptools/releases)
|
|
61
|
+
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
|
|
62
|
+
- [Commits](https://github.com/pypa/setuptools/compare/v80.10.1...v80.10.2)
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
updated-dependencies:
|
|
66
|
+
- dependency-name: setuptools
|
|
67
|
+
dependency-version: 80.10.2
|
|
68
|
+
dependency-type: direct:production
|
|
69
|
+
update-type: version-update:semver-patch
|
|
70
|
+
dependency-group: python-packages
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
0.61.3 (2026-01-24)
|
|
75
|
+
-------------------
|
|
7
76
|
- Fix KeyError: 'Private' when using --all flag (#481) [Rodos]
|
|
8
77
|
|
|
9
78
|
The repository dictionary uses lowercase "private" key. Use .get() with
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.61.5"
|
|
@@ -39,11 +39,11 @@ logger = logging.getLogger(__name__)
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class RepositoryUnavailableError(Exception):
|
|
42
|
-
"""Raised when a repository is unavailable due to legal reasons (e.g., DMCA takedown)."""
|
|
42
|
+
"""Raised when a repository is unavailable due to legal reasons (e.g., DMCA takedown, TOS violation)."""
|
|
43
43
|
|
|
44
|
-
def __init__(self, message,
|
|
44
|
+
def __init__(self, message, legal_url=None):
|
|
45
45
|
super().__init__(message)
|
|
46
|
-
self.
|
|
46
|
+
self.legal_url = legal_url
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
# Setup SSL context with fallback chain
|
|
@@ -647,6 +647,14 @@ def retrieve_data(args, template, query_args=None, paginated=True):
|
|
|
647
647
|
return None
|
|
648
648
|
|
|
649
649
|
def fetch_all() -> Generator[dict, None, None]:
|
|
650
|
+
def _extract_legal_url(response_body_bytes):
|
|
651
|
+
"""Extract DMCA/legal notice URL from GitHub API error response body."""
|
|
652
|
+
try:
|
|
653
|
+
data = json.loads(response_body_bytes.decode("utf-8"))
|
|
654
|
+
return data.get("block", {}).get("html_url")
|
|
655
|
+
except Exception:
|
|
656
|
+
return None
|
|
657
|
+
|
|
650
658
|
next_url = None
|
|
651
659
|
|
|
652
660
|
while True:
|
|
@@ -661,47 +669,66 @@ def retrieve_data(args, template, query_args=None, paginated=True):
|
|
|
661
669
|
as_app=args.as_app,
|
|
662
670
|
fine=args.token_fine is not None,
|
|
663
671
|
)
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
break # Exit retry loop and handle the data returned
|
|
672
|
-
except (
|
|
673
|
-
IncompleteRead,
|
|
674
|
-
json.decoder.JSONDecodeError,
|
|
675
|
-
TimeoutError,
|
|
676
|
-
) as e:
|
|
677
|
-
logger.warning(f"{type(e).__name__} reading response")
|
|
678
|
-
if attempt < args.max_retries:
|
|
679
|
-
delay = calculate_retry_delay(attempt, {})
|
|
680
|
-
logger.warning(
|
|
681
|
-
f"Retrying read in {delay:.1f}s (attempt {attempt + 1}/{args.max_retries + 1})"
|
|
682
|
-
)
|
|
683
|
-
time.sleep(delay)
|
|
684
|
-
continue # Next retry attempt
|
|
685
|
-
|
|
686
|
-
case 451:
|
|
687
|
-
# DMCA takedown - extract URL if available, then raise
|
|
688
|
-
dmca_url = None
|
|
689
|
-
try:
|
|
690
|
-
response_data = json.loads(
|
|
691
|
-
http_response.read().decode("utf-8")
|
|
692
|
-
)
|
|
693
|
-
dmca_url = response_data.get("block", {}).get("html_url")
|
|
694
|
-
except Exception:
|
|
695
|
-
pass
|
|
672
|
+
try:
|
|
673
|
+
http_response = make_request_with_retry(
|
|
674
|
+
request, auth, args.max_retries
|
|
675
|
+
)
|
|
676
|
+
except HTTPError as exc:
|
|
677
|
+
if exc.code == 451:
|
|
678
|
+
legal_url = _extract_legal_url(exc.read())
|
|
696
679
|
raise RepositoryUnavailableError(
|
|
697
|
-
"Repository unavailable due to legal reasons (HTTP
|
|
698
|
-
|
|
680
|
+
f"Repository unavailable due to legal reasons (HTTP {exc.code})",
|
|
681
|
+
legal_url=legal_url,
|
|
699
682
|
)
|
|
683
|
+
elif exc.code == 403:
|
|
684
|
+
# Rate-limit 403s (x-ratelimit-remaining=0) are retried
|
|
685
|
+
# by make_request_with_retry — re-raise if exhausted.
|
|
686
|
+
if int(exc.headers.get("x-ratelimit-remaining", 1)) < 1:
|
|
687
|
+
raise
|
|
688
|
+
# Only convert to RepositoryUnavailableError if GitHub
|
|
689
|
+
# indicates a TOS/DMCA block (response contains "block"
|
|
690
|
+
# key). Other 403s (permissions, scopes) should propagate.
|
|
691
|
+
body = exc.read()
|
|
692
|
+
try:
|
|
693
|
+
data = json.loads(body.decode("utf-8"))
|
|
694
|
+
except Exception:
|
|
695
|
+
data = {}
|
|
696
|
+
if "block" in data:
|
|
697
|
+
raise RepositoryUnavailableError(
|
|
698
|
+
"Repository access blocked (HTTP 403)",
|
|
699
|
+
legal_url=data.get("block", {}).get("html_url"),
|
|
700
|
+
)
|
|
701
|
+
raise
|
|
702
|
+
else:
|
|
703
|
+
raise
|
|
704
|
+
|
|
705
|
+
# urlopen raises HTTPError for non-2xx, so only success gets here.
|
|
706
|
+
# Guard against unexpected status codes from proxies, future Python
|
|
707
|
+
# changes, or other edge cases we haven't considered.
|
|
708
|
+
status = http_response.getcode()
|
|
709
|
+
if status != 200:
|
|
710
|
+
raise Exception(
|
|
711
|
+
f"Unexpected HTTP {status} from {next_url or template} "
|
|
712
|
+
f"(expected non-2xx to raise HTTPError)"
|
|
713
|
+
)
|
|
700
714
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
715
|
+
# Parse JSON response
|
|
716
|
+
try:
|
|
717
|
+
response = json.loads(http_response.read().decode("utf-8"))
|
|
718
|
+
break # Exit retry loop and handle the data returned
|
|
719
|
+
except (
|
|
720
|
+
IncompleteRead,
|
|
721
|
+
json.decoder.JSONDecodeError,
|
|
722
|
+
TimeoutError,
|
|
723
|
+
) as e:
|
|
724
|
+
logger.warning(f"{type(e).__name__} reading response")
|
|
725
|
+
if attempt < args.max_retries:
|
|
726
|
+
delay = calculate_retry_delay(attempt, {})
|
|
727
|
+
logger.warning(
|
|
728
|
+
f"Retrying read in {delay:.1f}s (attempt {attempt + 1}/{args.max_retries + 1})"
|
|
704
729
|
)
|
|
730
|
+
time.sleep(delay)
|
|
731
|
+
continue # Next retry attempt
|
|
705
732
|
else:
|
|
706
733
|
logger.error(
|
|
707
734
|
f"Failed to read response after {args.max_retries + 1} attempts for {next_url or template}"
|
|
@@ -1614,7 +1641,13 @@ def retrieve_repositories(args, authenticated_user):
|
|
|
1614
1641
|
paginated = False
|
|
1615
1642
|
template = "https://{0}/repos/{1}".format(get_github_api_host(args), repo_path)
|
|
1616
1643
|
|
|
1617
|
-
|
|
1644
|
+
try:
|
|
1645
|
+
repos = retrieve_data(args, template, paginated=paginated)
|
|
1646
|
+
except RepositoryUnavailableError as e:
|
|
1647
|
+
logger.warning(f"Repository is unavailable: {e}")
|
|
1648
|
+
if e.legal_url:
|
|
1649
|
+
logger.warning(f"Legal notice: {e.legal_url}")
|
|
1650
|
+
return []
|
|
1618
1651
|
|
|
1619
1652
|
if args.all_starred:
|
|
1620
1653
|
starred_template = "https://{0}/users/{1}/starred".format(
|
|
@@ -1739,9 +1772,9 @@ def backup_repositories(args, output_directory, repositories):
|
|
|
1739
1772
|
|
|
1740
1773
|
last_update = "0000-00-00T00:00:00Z"
|
|
1741
1774
|
for repository in repositories:
|
|
1742
|
-
if "updated_at"
|
|
1775
|
+
if repository.get("updated_at") and repository["updated_at"] > last_update:
|
|
1743
1776
|
last_update = repository["updated_at"]
|
|
1744
|
-
elif "pushed_at"
|
|
1777
|
+
elif repository.get("pushed_at") and repository["pushed_at"] > last_update:
|
|
1745
1778
|
last_update = repository["pushed_at"]
|
|
1746
1779
|
|
|
1747
1780
|
if repository.get("is_gist"):
|
|
@@ -1832,11 +1865,9 @@ def backup_repositories(args, output_directory, repositories):
|
|
|
1832
1865
|
include_assets=args.include_assets or args.include_everything,
|
|
1833
1866
|
)
|
|
1834
1867
|
except RepositoryUnavailableError as e:
|
|
1835
|
-
logger.warning(
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
if e.dmca_url:
|
|
1839
|
-
logger.warning(f"DMCA notice: {e.dmca_url}")
|
|
1868
|
+
logger.warning(f"Repository {repository['full_name']} is unavailable: {e}")
|
|
1869
|
+
if e.legal_url:
|
|
1870
|
+
logger.warning(f"Legal notice: {e.legal_url}")
|
|
1840
1871
|
logger.info(f"Skipping remaining resources for {repository['full_name']}")
|
|
1841
1872
|
continue
|
|
1842
1873
|
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Tests for HTTP 451 (DMCA takedown) and HTTP 403 (TOS) handling."""
|
|
2
|
+
|
|
3
|
+
import io
|
|
4
|
+
import json
|
|
5
|
+
from unittest.mock import patch
|
|
6
|
+
from urllib.error import HTTPError
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from github_backup import github_backup
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _make_http_error(code, body_bytes, msg="Error", headers=None):
|
|
14
|
+
"""Create an HTTPError with a readable body (like a real urllib response)."""
|
|
15
|
+
if headers is None:
|
|
16
|
+
headers = {"x-ratelimit-remaining": "5000"}
|
|
17
|
+
return HTTPError(
|
|
18
|
+
url="https://api.github.com/repos/test/repo",
|
|
19
|
+
code=code,
|
|
20
|
+
msg=msg,
|
|
21
|
+
hdrs=headers,
|
|
22
|
+
fp=io.BytesIO(body_bytes),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TestHTTP451Exception:
|
|
27
|
+
"""Test suite for HTTP 451 DMCA takedown exception handling."""
|
|
28
|
+
|
|
29
|
+
def test_repository_unavailable_error_raised(self, create_args):
|
|
30
|
+
"""HTTP 451 should raise RepositoryUnavailableError with DMCA URL."""
|
|
31
|
+
args = create_args()
|
|
32
|
+
|
|
33
|
+
dmca_data = {
|
|
34
|
+
"message": "Repository access blocked",
|
|
35
|
+
"block": {
|
|
36
|
+
"reason": "dmca",
|
|
37
|
+
"created_at": "2024-11-12T14:38:04Z",
|
|
38
|
+
"html_url": "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md",
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
body = json.dumps(dmca_data).encode("utf-8")
|
|
42
|
+
|
|
43
|
+
def mock_urlopen(*a, **kw):
|
|
44
|
+
raise _make_http_error(451, body, msg="Unavailable For Legal Reasons")
|
|
45
|
+
|
|
46
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
47
|
+
with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
|
|
48
|
+
github_backup.retrieve_data(
|
|
49
|
+
args, "https://api.github.com/repos/test/dmca/issues"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
assert (
|
|
53
|
+
exc_info.value.legal_url
|
|
54
|
+
== "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md"
|
|
55
|
+
)
|
|
56
|
+
assert "451" in str(exc_info.value)
|
|
57
|
+
|
|
58
|
+
def test_repository_unavailable_error_without_legal_url(self, create_args):
|
|
59
|
+
"""HTTP 451 without DMCA details should still raise exception."""
|
|
60
|
+
args = create_args()
|
|
61
|
+
|
|
62
|
+
def mock_urlopen(*a, **kw):
|
|
63
|
+
raise _make_http_error(451, b'{"message": "Blocked"}')
|
|
64
|
+
|
|
65
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
66
|
+
with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
|
|
67
|
+
github_backup.retrieve_data(
|
|
68
|
+
args, "https://api.github.com/repos/test/dmca/issues"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
assert exc_info.value.legal_url is None
|
|
72
|
+
assert "451" in str(exc_info.value)
|
|
73
|
+
|
|
74
|
+
def test_repository_unavailable_error_with_malformed_json(self, create_args):
|
|
75
|
+
"""HTTP 451 with malformed JSON should still raise exception."""
|
|
76
|
+
args = create_args()
|
|
77
|
+
|
|
78
|
+
def mock_urlopen(*a, **kw):
|
|
79
|
+
raise _make_http_error(451, b"invalid json {")
|
|
80
|
+
|
|
81
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
82
|
+
with pytest.raises(github_backup.RepositoryUnavailableError):
|
|
83
|
+
github_backup.retrieve_data(
|
|
84
|
+
args, "https://api.github.com/repos/test/dmca/issues"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TestHTTP403TOS:
|
|
89
|
+
"""Test suite for HTTP 403 TOS violation handling."""
|
|
90
|
+
|
|
91
|
+
def test_403_tos_raises_repository_unavailable(self, create_args):
|
|
92
|
+
"""HTTP 403 (non-rate-limit) should raise RepositoryUnavailableError."""
|
|
93
|
+
args = create_args()
|
|
94
|
+
|
|
95
|
+
tos_data = {
|
|
96
|
+
"message": "Repository access blocked",
|
|
97
|
+
"block": {
|
|
98
|
+
"reason": "tos",
|
|
99
|
+
"html_url": "https://github.com/contact/tos-violation",
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
body = json.dumps(tos_data).encode("utf-8")
|
|
103
|
+
|
|
104
|
+
def mock_urlopen(*a, **kw):
|
|
105
|
+
raise _make_http_error(
|
|
106
|
+
403,
|
|
107
|
+
body,
|
|
108
|
+
msg="Forbidden",
|
|
109
|
+
headers={"x-ratelimit-remaining": "5000"},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
113
|
+
with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
|
|
114
|
+
github_backup.retrieve_data(
|
|
115
|
+
args, "https://api.github.com/repos/test/blocked/issues"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
assert (
|
|
119
|
+
exc_info.value.legal_url == "https://github.com/contact/tos-violation"
|
|
120
|
+
)
|
|
121
|
+
assert "403" in str(exc_info.value)
|
|
122
|
+
|
|
123
|
+
def test_403_permission_denied_not_converted(self, create_args):
|
|
124
|
+
"""HTTP 403 without 'block' in body should propagate as HTTPError, not RepositoryUnavailableError."""
|
|
125
|
+
args = create_args()
|
|
126
|
+
|
|
127
|
+
body = json.dumps({"message": "Must have admin rights to Repository."}).encode(
|
|
128
|
+
"utf-8"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def mock_urlopen(*a, **kw):
|
|
132
|
+
raise _make_http_error(
|
|
133
|
+
403,
|
|
134
|
+
body,
|
|
135
|
+
msg="Forbidden",
|
|
136
|
+
headers={"x-ratelimit-remaining": "5000"},
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
140
|
+
with pytest.raises(HTTPError) as exc_info:
|
|
141
|
+
github_backup.retrieve_data(
|
|
142
|
+
args, "https://api.github.com/repos/test/private/issues"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
assert exc_info.value.code == 403
|
|
146
|
+
|
|
147
|
+
def test_403_rate_limit_not_converted(self, create_args):
|
|
148
|
+
"""HTTP 403 with rate limit exhausted should NOT become RepositoryUnavailableError."""
|
|
149
|
+
args = create_args()
|
|
150
|
+
|
|
151
|
+
call_count = 0
|
|
152
|
+
|
|
153
|
+
def mock_urlopen(*a, **kw):
|
|
154
|
+
nonlocal call_count
|
|
155
|
+
call_count += 1
|
|
156
|
+
raise _make_http_error(
|
|
157
|
+
403,
|
|
158
|
+
b'{"message": "rate limit"}',
|
|
159
|
+
msg="Forbidden",
|
|
160
|
+
headers={"x-ratelimit-remaining": "0"},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
164
|
+
with patch(
|
|
165
|
+
"github_backup.github_backup.calculate_retry_delay", return_value=0
|
|
166
|
+
):
|
|
167
|
+
with pytest.raises(HTTPError) as exc_info:
|
|
168
|
+
github_backup.retrieve_data(
|
|
169
|
+
args, "https://api.github.com/repos/test/ratelimit/issues"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
assert exc_info.value.code == 403
|
|
173
|
+
# Should have retried (not raised immediately as RepositoryUnavailableError)
|
|
174
|
+
assert call_count > 1
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TestRetrieveRepositoriesUnavailable:
|
|
178
|
+
"""Test that retrieve_repositories handles RepositoryUnavailableError gracefully."""
|
|
179
|
+
|
|
180
|
+
def test_unavailable_repo_returns_empty_list(self, create_args):
|
|
181
|
+
"""retrieve_repositories should return [] when the repo is unavailable."""
|
|
182
|
+
args = create_args(repository="blocked-repo")
|
|
183
|
+
|
|
184
|
+
def mock_urlopen(*a, **kw):
|
|
185
|
+
raise _make_http_error(
|
|
186
|
+
451,
|
|
187
|
+
json.dumps(
|
|
188
|
+
{
|
|
189
|
+
"message": "Blocked",
|
|
190
|
+
"block": {"html_url": "https://example.com/dmca"},
|
|
191
|
+
}
|
|
192
|
+
).encode("utf-8"),
|
|
193
|
+
msg="Unavailable For Legal Reasons",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
197
|
+
repos = github_backup.retrieve_repositories(args, {"login": None})
|
|
198
|
+
|
|
199
|
+
assert repos == []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
pytest.main([__file__, "-v"])
|
|
@@ -288,6 +288,28 @@ class TestMakeRequestWithRetry:
|
|
|
288
288
|
assert exc_info.value.code == 403
|
|
289
289
|
assert call_count == 1 # No retries
|
|
290
290
|
|
|
291
|
+
def test_451_error_not_retried(self):
|
|
292
|
+
"""HTTP 451 should not be retried - raise immediately."""
|
|
293
|
+
call_count = 0
|
|
294
|
+
|
|
295
|
+
def mock_urlopen(*args, **kwargs):
|
|
296
|
+
nonlocal call_count
|
|
297
|
+
call_count += 1
|
|
298
|
+
raise HTTPError(
|
|
299
|
+
url="https://api.github.com/test",
|
|
300
|
+
code=451,
|
|
301
|
+
msg="Unavailable For Legal Reasons",
|
|
302
|
+
hdrs={"x-ratelimit-remaining": "5000"},
|
|
303
|
+
fp=None,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
with patch("github_backup.github_backup.urlopen", side_effect=mock_urlopen):
|
|
307
|
+
with pytest.raises(HTTPError) as exc_info:
|
|
308
|
+
make_request_with_retry(Mock(), None)
|
|
309
|
+
|
|
310
|
+
assert exc_info.value.code == 451
|
|
311
|
+
assert call_count == 1 # No retries
|
|
312
|
+
|
|
291
313
|
def test_connection_error_retries_and_succeeds(self):
|
|
292
314
|
"""URLError (connection error) should retry and succeed if subsequent request works."""
|
|
293
315
|
good_response = Mock()
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.61.3"
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
"""Tests for HTTP 451 (DMCA takedown) handling."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
from unittest.mock import Mock, patch
|
|
5
|
-
|
|
6
|
-
import pytest
|
|
7
|
-
|
|
8
|
-
from github_backup import github_backup
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
class TestHTTP451Exception:
|
|
12
|
-
"""Test suite for HTTP 451 DMCA takedown exception handling."""
|
|
13
|
-
|
|
14
|
-
def test_repository_unavailable_error_raised(self, create_args):
|
|
15
|
-
"""HTTP 451 should raise RepositoryUnavailableError with DMCA URL."""
|
|
16
|
-
args = create_args()
|
|
17
|
-
|
|
18
|
-
mock_response = Mock()
|
|
19
|
-
mock_response.getcode.return_value = 451
|
|
20
|
-
|
|
21
|
-
dmca_data = {
|
|
22
|
-
"message": "Repository access blocked",
|
|
23
|
-
"block": {
|
|
24
|
-
"reason": "dmca",
|
|
25
|
-
"created_at": "2024-11-12T14:38:04Z",
|
|
26
|
-
"html_url": "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md",
|
|
27
|
-
},
|
|
28
|
-
}
|
|
29
|
-
mock_response.read.return_value = json.dumps(dmca_data).encode("utf-8")
|
|
30
|
-
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
31
|
-
mock_response.reason = "Unavailable For Legal Reasons"
|
|
32
|
-
|
|
33
|
-
with patch(
|
|
34
|
-
"github_backup.github_backup.make_request_with_retry",
|
|
35
|
-
return_value=mock_response,
|
|
36
|
-
):
|
|
37
|
-
with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
|
|
38
|
-
github_backup.retrieve_data(
|
|
39
|
-
args, "https://api.github.com/repos/test/dmca/issues"
|
|
40
|
-
)
|
|
41
|
-
|
|
42
|
-
assert (
|
|
43
|
-
exc_info.value.dmca_url
|
|
44
|
-
== "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md"
|
|
45
|
-
)
|
|
46
|
-
assert "451" in str(exc_info.value)
|
|
47
|
-
|
|
48
|
-
def test_repository_unavailable_error_without_dmca_url(self, create_args):
|
|
49
|
-
"""HTTP 451 without DMCA details should still raise exception."""
|
|
50
|
-
args = create_args()
|
|
51
|
-
|
|
52
|
-
mock_response = Mock()
|
|
53
|
-
mock_response.getcode.return_value = 451
|
|
54
|
-
mock_response.read.return_value = b'{"message": "Blocked"}'
|
|
55
|
-
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
56
|
-
mock_response.reason = "Unavailable For Legal Reasons"
|
|
57
|
-
|
|
58
|
-
with patch(
|
|
59
|
-
"github_backup.github_backup.make_request_with_retry",
|
|
60
|
-
return_value=mock_response,
|
|
61
|
-
):
|
|
62
|
-
with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
|
|
63
|
-
github_backup.retrieve_data(
|
|
64
|
-
args, "https://api.github.com/repos/test/dmca/issues"
|
|
65
|
-
)
|
|
66
|
-
|
|
67
|
-
assert exc_info.value.dmca_url is None
|
|
68
|
-
assert "451" in str(exc_info.value)
|
|
69
|
-
|
|
70
|
-
def test_repository_unavailable_error_with_malformed_json(self, create_args):
|
|
71
|
-
"""HTTP 451 with malformed JSON should still raise exception."""
|
|
72
|
-
args = create_args()
|
|
73
|
-
|
|
74
|
-
mock_response = Mock()
|
|
75
|
-
mock_response.getcode.return_value = 451
|
|
76
|
-
mock_response.read.return_value = b"invalid json {"
|
|
77
|
-
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
78
|
-
mock_response.reason = "Unavailable For Legal Reasons"
|
|
79
|
-
|
|
80
|
-
with patch(
|
|
81
|
-
"github_backup.github_backup.make_request_with_retry",
|
|
82
|
-
return_value=mock_response,
|
|
83
|
-
):
|
|
84
|
-
with pytest.raises(github_backup.RepositoryUnavailableError):
|
|
85
|
-
github_backup.retrieve_data(
|
|
86
|
-
args, "https://api.github.com/repos/test/dmca/issues"
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
if __name__ == "__main__":
|
|
91
|
-
pytest.main([__file__, "-v"])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|