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.
Files changed (31) hide show
  1. {github_backup-0.61.3 → github_backup-0.61.5}/CHANGES.rst +70 -1
  2. {github_backup-0.61.3/github_backup.egg-info → github_backup-0.61.5}/PKG-INFO +1 -1
  3. github_backup-0.61.5/github_backup/__init__.py +1 -0
  4. {github_backup-0.61.3 → github_backup-0.61.5}/github_backup/github_backup.py +79 -48
  5. {github_backup-0.61.3 → github_backup-0.61.5/github_backup.egg-info}/PKG-INFO +1 -1
  6. {github_backup-0.61.3 → github_backup-0.61.5}/release-requirements.txt +1 -1
  7. github_backup-0.61.5/tests/test_http_451.py +203 -0
  8. {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_retrieve_data.py +22 -0
  9. github_backup-0.61.3/github_backup/__init__.py +0 -1
  10. github_backup-0.61.3/tests/test_http_451.py +0 -91
  11. {github_backup-0.61.3 → github_backup-0.61.5}/LICENSE.txt +0 -0
  12. {github_backup-0.61.3 → github_backup-0.61.5}/MANIFEST.in +0 -0
  13. {github_backup-0.61.3 → github_backup-0.61.5}/README +0 -0
  14. {github_backup-0.61.3 → github_backup-0.61.5}/README.rst +0 -0
  15. {github_backup-0.61.3 → github_backup-0.61.5}/github_backup/__main__.py +0 -0
  16. {github_backup-0.61.3 → github_backup-0.61.5}/github_backup/cli.py +0 -0
  17. {github_backup-0.61.3 → github_backup-0.61.5}/github_backup.egg-info/SOURCES.txt +0 -0
  18. {github_backup-0.61.3 → github_backup-0.61.5}/github_backup.egg-info/dependency_links.txt +0 -0
  19. {github_backup-0.61.3 → github_backup-0.61.5}/github_backup.egg-info/entry_points.txt +0 -0
  20. {github_backup-0.61.3 → github_backup-0.61.5}/github_backup.egg-info/top_level.txt +0 -0
  21. {github_backup-0.61.3 → github_backup-0.61.5}/github_backup.egg-info/zip-safe +0 -0
  22. {github_backup-0.61.3 → github_backup-0.61.5}/requirements.txt +0 -0
  23. {github_backup-0.61.3 → github_backup-0.61.5}/setup.cfg +0 -0
  24. {github_backup-0.61.3 → github_backup-0.61.5}/setup.py +0 -0
  25. {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_all_starred.py +0 -0
  26. {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_attachments.py +0 -0
  27. {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_case_sensitivity.py +0 -0
  28. {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_json_dump_if_changed.py +0 -0
  29. {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_pagination.py +0 -0
  30. {github_backup-0.61.3 → github_backup-0.61.5}/tests/test_skip_assets_on.py +0 -0
  31. {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.3 (2026-01-24)
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.61.3
3
+ Version: 0.61.5
4
4
  Summary: backup a github user or organization
5
5
  Home-page: http://github.com/josegonzalez/python-github-backup
6
6
  Author: Jose Diaz-Gonzalez
@@ -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, dmca_url=None):
44
+ def __init__(self, message, legal_url=None):
45
45
  super().__init__(message)
46
- self.dmca_url = dmca_url
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
- http_response = make_request_with_retry(request, auth, args.max_retries)
665
-
666
- match http_response.getcode():
667
- case 200:
668
- # Success - Parse JSON response
669
- try:
670
- response = json.loads(http_response.read().decode("utf-8"))
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 451)",
698
- dmca_url=dmca_url,
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
- case _:
702
- raise Exception(
703
- f"API request returned HTTP {http_response.getcode()}: {http_response.reason}"
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
- repos = retrieve_data(args, template, paginated=paginated)
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" in repository and repository["updated_at"] > last_update:
1775
+ if repository.get("updated_at") and repository["updated_at"] > last_update:
1743
1776
  last_update = repository["updated_at"]
1744
- elif "pushed_at" in repository and repository["pushed_at"] > last_update:
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
- f"Repository {repository['full_name']} is unavailable (HTTP 451)"
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
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.61.3
3
+ Version: 0.61.5
4
4
  Summary: backup a github user or organization
5
5
  Home-page: http://github.com/josegonzalez/python-github-backup
6
6
  Author: Jose Diaz-Gonzalez
@@ -9,7 +9,7 @@ pytest==9.0.2
9
9
  # Release & Publishing
10
10
  twine==6.2.0
11
11
  gitchangelog==3.0.4
12
- setuptools==80.10.1
12
+ setuptools==82.0.0
13
13
 
14
14
  # Documentation
15
15
  restructuredtext-lint==2.0.2
@@ -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