github-backup 0.51.3__tar.gz → 0.52.0__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.51.3 → github_backup-0.52.0}/CHANGES.rst +82 -1
- {github_backup-0.51.3/github_backup.egg-info → github_backup-0.52.0}/PKG-INFO +1 -1
- github_backup-0.52.0/github_backup/__init__.py +1 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/github_backup/github_backup.py +58 -29
- {github_backup-0.51.3 → github_backup-0.52.0/github_backup.egg-info}/PKG-INFO +1 -1
- {github_backup-0.51.3 → github_backup-0.52.0}/github_backup.egg-info/SOURCES.txt +1 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/release-requirements.txt +4 -4
- github_backup-0.52.0/tests/test_http_451.py +143 -0
- github_backup-0.51.3/github_backup/__init__.py +0 -1
- {github_backup-0.51.3 → github_backup-0.52.0}/LICENSE.txt +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/MANIFEST.in +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/README +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/README.rst +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/bin/github-backup +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/github_backup.egg-info/dependency_links.txt +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/github_backup.egg-info/top_level.txt +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/github_backup.egg-info/zip-safe +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/requirements.txt +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/setup.cfg +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/setup.py +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/tests/test_attachments.py +0 -0
- {github_backup-0.51.3 → github_backup-0.52.0}/tests/test_pagination.py +0 -0
|
@@ -1,9 +1,90 @@
|
|
|
1
1
|
Changelog
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
-
0.
|
|
4
|
+
0.52.0 (2025-11-28)
|
|
5
5
|
-------------------
|
|
6
6
|
------------------------
|
|
7
|
+
- Skip DMCA'd repos which return a 451 response. [Rodos]
|
|
8
|
+
|
|
9
|
+
Log a warning and the link to the DMCA notice. Continue backing up
|
|
10
|
+
other repositories instead of crashing.
|
|
11
|
+
|
|
12
|
+
Closes #163
|
|
13
|
+
- Chore(deps): bump restructuredtext-lint in the python-packages group.
|
|
14
|
+
[dependabot[bot]]
|
|
15
|
+
|
|
16
|
+
Bumps the python-packages group with 1 update: [restructuredtext-lint](https://github.com/twolfson/restructuredtext-lint).
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
Updates `restructuredtext-lint` from 1.4.0 to 2.0.2
|
|
20
|
+
- [Changelog](https://github.com/twolfson/restructuredtext-lint/blob/master/CHANGELOG.rst)
|
|
21
|
+
- [Commits](https://github.com/twolfson/restructuredtext-lint/compare/1.4.0...2.0.2)
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
updated-dependencies:
|
|
25
|
+
- dependency-name: restructuredtext-lint
|
|
26
|
+
dependency-version: 2.0.2
|
|
27
|
+
dependency-type: direct:production
|
|
28
|
+
update-type: version-update:semver-major
|
|
29
|
+
dependency-group: python-packages
|
|
30
|
+
...
|
|
31
|
+
- Chore(deps): bump actions/checkout from 5 to 6. [dependabot[bot]]
|
|
32
|
+
|
|
33
|
+
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
|
|
34
|
+
- [Release notes](https://github.com/actions/checkout/releases)
|
|
35
|
+
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
|
|
36
|
+
- [Commits](https://github.com/actions/checkout/compare/v5...v6)
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
updated-dependencies:
|
|
40
|
+
- dependency-name: actions/checkout
|
|
41
|
+
dependency-version: '6'
|
|
42
|
+
dependency-type: direct:production
|
|
43
|
+
update-type: version-update:semver-major
|
|
44
|
+
...
|
|
45
|
+
- Chore(deps): bump the python-packages group with 3 updates.
|
|
46
|
+
[dependabot[bot]]
|
|
47
|
+
|
|
48
|
+
Bumps the python-packages group with 3 updates: [click](https://github.com/pallets/click), [pytest](https://github.com/pytest-dev/pytest) and [keyring](https://github.com/jaraco/keyring).
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
Updates `click` from 8.3.0 to 8.3.1
|
|
52
|
+
- [Release notes](https://github.com/pallets/click/releases)
|
|
53
|
+
- [Changelog](https://github.com/pallets/click/blob/main/CHANGES.rst)
|
|
54
|
+
- [Commits](https://github.com/pallets/click/compare/8.3.0...8.3.1)
|
|
55
|
+
|
|
56
|
+
Updates `pytest` from 8.3.3 to 9.0.1
|
|
57
|
+
- [Release notes](https://github.com/pytest-dev/pytest/releases)
|
|
58
|
+
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
|
|
59
|
+
- [Commits](https://github.com/pytest-dev/pytest/compare/8.3.3...9.0.1)
|
|
60
|
+
|
|
61
|
+
Updates `keyring` from 25.6.0 to 25.7.0
|
|
62
|
+
- [Release notes](https://github.com/jaraco/keyring/releases)
|
|
63
|
+
- [Changelog](https://github.com/jaraco/keyring/blob/main/NEWS.rst)
|
|
64
|
+
- [Commits](https://github.com/jaraco/keyring/compare/v25.6.0...v25.7.0)
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
updated-dependencies:
|
|
68
|
+
- dependency-name: click
|
|
69
|
+
dependency-version: 8.3.1
|
|
70
|
+
dependency-type: direct:production
|
|
71
|
+
update-type: version-update:semver-patch
|
|
72
|
+
dependency-group: python-packages
|
|
73
|
+
- dependency-name: pytest
|
|
74
|
+
dependency-version: 9.0.1
|
|
75
|
+
dependency-type: direct:production
|
|
76
|
+
update-type: version-update:semver-major
|
|
77
|
+
dependency-group: python-packages
|
|
78
|
+
- dependency-name: keyring
|
|
79
|
+
dependency-version: 25.7.0
|
|
80
|
+
dependency-type: direct:production
|
|
81
|
+
update-type: version-update:semver-minor
|
|
82
|
+
dependency-group: python-packages
|
|
83
|
+
...
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
0.51.3 (2025-11-18)
|
|
87
|
+
-------------------
|
|
7
88
|
- Test: Add pagination tests for cursor and page-based Link headers.
|
|
8
89
|
[Rodos]
|
|
9
90
|
- Use cursor based pagination. [Helio Machado]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.52.0"
|
|
@@ -37,6 +37,15 @@ FNULL = open(os.devnull, "w")
|
|
|
37
37
|
FILE_URI_PREFIX = "file://"
|
|
38
38
|
logger = logging.getLogger(__name__)
|
|
39
39
|
|
|
40
|
+
|
|
41
|
+
class RepositoryUnavailableError(Exception):
|
|
42
|
+
"""Raised when a repository is unavailable due to legal reasons (e.g., DMCA takedown)."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message, dmca_url=None):
|
|
45
|
+
super().__init__(message)
|
|
46
|
+
self.dmca_url = dmca_url
|
|
47
|
+
|
|
48
|
+
|
|
40
49
|
# Setup SSL context with fallback chain
|
|
41
50
|
https_ctx = ssl.create_default_context()
|
|
42
51
|
if https_ctx.get_ca_certs():
|
|
@@ -612,6 +621,19 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
|
|
|
612
621
|
|
|
613
622
|
status_code = int(r.getcode())
|
|
614
623
|
|
|
624
|
+
# Handle DMCA takedown (HTTP 451) - raise exception to skip entire repository
|
|
625
|
+
if status_code == 451:
|
|
626
|
+
dmca_url = None
|
|
627
|
+
try:
|
|
628
|
+
response_data = json.loads(r.read().decode("utf-8"))
|
|
629
|
+
dmca_url = response_data.get("block", {}).get("html_url")
|
|
630
|
+
except Exception:
|
|
631
|
+
pass
|
|
632
|
+
raise RepositoryUnavailableError(
|
|
633
|
+
"Repository unavailable due to legal reasons (HTTP 451)",
|
|
634
|
+
dmca_url=dmca_url
|
|
635
|
+
)
|
|
636
|
+
|
|
615
637
|
# Check if we got correct data
|
|
616
638
|
try:
|
|
617
639
|
response = json.loads(r.read().decode("utf-8"))
|
|
@@ -1668,40 +1690,47 @@ def backup_repositories(args, output_directory, repositories):
|
|
|
1668
1690
|
|
|
1669
1691
|
continue # don't try to back anything else for a gist; it doesn't exist
|
|
1670
1692
|
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1693
|
+
try:
|
|
1694
|
+
download_wiki = args.include_wiki or args.include_everything
|
|
1695
|
+
if repository["has_wiki"] and download_wiki:
|
|
1696
|
+
fetch_repository(
|
|
1697
|
+
repository["name"],
|
|
1698
|
+
repo_url.replace(".git", ".wiki.git"),
|
|
1699
|
+
os.path.join(repo_cwd, "wiki"),
|
|
1700
|
+
skip_existing=args.skip_existing,
|
|
1701
|
+
bare_clone=args.bare_clone,
|
|
1702
|
+
lfs_clone=args.lfs_clone,
|
|
1703
|
+
no_prune=args.no_prune,
|
|
1704
|
+
)
|
|
1705
|
+
if args.include_issues or args.include_everything:
|
|
1706
|
+
backup_issues(args, repo_cwd, repository, repos_template)
|
|
1684
1707
|
|
|
1685
|
-
|
|
1686
|
-
|
|
1708
|
+
if args.include_pulls or args.include_everything:
|
|
1709
|
+
backup_pulls(args, repo_cwd, repository, repos_template)
|
|
1687
1710
|
|
|
1688
|
-
|
|
1689
|
-
|
|
1711
|
+
if args.include_milestones or args.include_everything:
|
|
1712
|
+
backup_milestones(args, repo_cwd, repository, repos_template)
|
|
1690
1713
|
|
|
1691
|
-
|
|
1692
|
-
|
|
1714
|
+
if args.include_labels or args.include_everything:
|
|
1715
|
+
backup_labels(args, repo_cwd, repository, repos_template)
|
|
1693
1716
|
|
|
1694
|
-
|
|
1695
|
-
|
|
1717
|
+
if args.include_hooks or args.include_everything:
|
|
1718
|
+
backup_hooks(args, repo_cwd, repository, repos_template)
|
|
1696
1719
|
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1720
|
+
if args.include_releases or args.include_everything:
|
|
1721
|
+
backup_releases(
|
|
1722
|
+
args,
|
|
1723
|
+
repo_cwd,
|
|
1724
|
+
repository,
|
|
1725
|
+
repos_template,
|
|
1726
|
+
include_assets=args.include_assets or args.include_everything,
|
|
1727
|
+
)
|
|
1728
|
+
except RepositoryUnavailableError as e:
|
|
1729
|
+
logger.warning(f"Repository {repository['full_name']} is unavailable (HTTP 451)")
|
|
1730
|
+
if e.dmca_url:
|
|
1731
|
+
logger.warning(f"DMCA notice: {e.dmca_url}")
|
|
1732
|
+
logger.info(f"Skipping remaining resources for {repository['full_name']}")
|
|
1733
|
+
continue
|
|
1705
1734
|
|
|
1706
1735
|
if args.incremental:
|
|
1707
1736
|
if last_update == "0000-00-00T00:00:00Z":
|
|
@@ -3,16 +3,16 @@ black==25.11.0
|
|
|
3
3
|
bleach==6.3.0
|
|
4
4
|
certifi==2025.11.12
|
|
5
5
|
charset-normalizer==3.4.4
|
|
6
|
-
click==8.3.
|
|
6
|
+
click==8.3.1
|
|
7
7
|
colorama==0.4.6
|
|
8
8
|
docutils==0.22.3
|
|
9
9
|
flake8==7.3.0
|
|
10
10
|
gitchangelog==3.0.4
|
|
11
|
-
pytest==
|
|
11
|
+
pytest==9.0.1
|
|
12
12
|
idna==3.11
|
|
13
13
|
importlib-metadata==8.7.0
|
|
14
14
|
jaraco.classes==3.4.0
|
|
15
|
-
keyring==25.
|
|
15
|
+
keyring==25.7.0
|
|
16
16
|
markdown-it-py==4.0.0
|
|
17
17
|
mccabe==0.7.0
|
|
18
18
|
mdurl==0.1.2
|
|
@@ -28,7 +28,7 @@ Pygments==2.19.2
|
|
|
28
28
|
readme-renderer==44.0
|
|
29
29
|
requests==2.32.5
|
|
30
30
|
requests-toolbelt==1.0.0
|
|
31
|
-
restructuredtext-lint==
|
|
31
|
+
restructuredtext-lint==2.0.2
|
|
32
32
|
rfc3986==2.0.0
|
|
33
33
|
rich==14.2.0
|
|
34
34
|
setuptools==80.9.0
|
|
@@ -0,0 +1,143 @@
|
|
|
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):
|
|
15
|
+
"""HTTP 451 should raise RepositoryUnavailableError with DMCA URL."""
|
|
16
|
+
# Create mock args
|
|
17
|
+
args = Mock()
|
|
18
|
+
args.as_app = False
|
|
19
|
+
args.token_fine = None
|
|
20
|
+
args.token_classic = None
|
|
21
|
+
args.username = None
|
|
22
|
+
args.password = None
|
|
23
|
+
args.osx_keychain_item_name = None
|
|
24
|
+
args.osx_keychain_item_account = None
|
|
25
|
+
args.throttle_limit = None
|
|
26
|
+
args.throttle_pause = 0
|
|
27
|
+
|
|
28
|
+
# Mock HTTPError 451 response
|
|
29
|
+
mock_response = Mock()
|
|
30
|
+
mock_response.getcode.return_value = 451
|
|
31
|
+
|
|
32
|
+
dmca_data = {
|
|
33
|
+
"message": "Repository access blocked",
|
|
34
|
+
"block": {
|
|
35
|
+
"reason": "dmca",
|
|
36
|
+
"created_at": "2024-11-12T14:38:04Z",
|
|
37
|
+
"html_url": "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
mock_response.read.return_value = json.dumps(dmca_data).encode("utf-8")
|
|
41
|
+
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
42
|
+
mock_response.reason = "Unavailable For Legal Reasons"
|
|
43
|
+
|
|
44
|
+
def mock_get_response(request, auth, template):
|
|
45
|
+
return mock_response, []
|
|
46
|
+
|
|
47
|
+
with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
|
|
48
|
+
with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
|
|
49
|
+
list(github_backup.retrieve_data_gen(args, "https://api.github.com/repos/test/dmca/issues"))
|
|
50
|
+
|
|
51
|
+
# Check exception has DMCA URL
|
|
52
|
+
assert exc_info.value.dmca_url == "https://github.com/github/dmca/blob/master/2024/11/2024-11-04-source-code.md"
|
|
53
|
+
assert "451" in str(exc_info.value)
|
|
54
|
+
|
|
55
|
+
def test_repository_unavailable_error_without_dmca_url(self):
|
|
56
|
+
"""HTTP 451 without DMCA details should still raise exception."""
|
|
57
|
+
args = Mock()
|
|
58
|
+
args.as_app = False
|
|
59
|
+
args.token_fine = None
|
|
60
|
+
args.token_classic = None
|
|
61
|
+
args.username = None
|
|
62
|
+
args.password = None
|
|
63
|
+
args.osx_keychain_item_name = None
|
|
64
|
+
args.osx_keychain_item_account = None
|
|
65
|
+
args.throttle_limit = None
|
|
66
|
+
args.throttle_pause = 0
|
|
67
|
+
|
|
68
|
+
mock_response = Mock()
|
|
69
|
+
mock_response.getcode.return_value = 451
|
|
70
|
+
mock_response.read.return_value = b'{"message": "Blocked"}'
|
|
71
|
+
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
72
|
+
mock_response.reason = "Unavailable For Legal Reasons"
|
|
73
|
+
|
|
74
|
+
def mock_get_response(request, auth, template):
|
|
75
|
+
return mock_response, []
|
|
76
|
+
|
|
77
|
+
with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
|
|
78
|
+
with pytest.raises(github_backup.RepositoryUnavailableError) as exc_info:
|
|
79
|
+
list(github_backup.retrieve_data_gen(args, "https://api.github.com/repos/test/dmca/issues"))
|
|
80
|
+
|
|
81
|
+
# Exception raised even without DMCA URL
|
|
82
|
+
assert exc_info.value.dmca_url is None
|
|
83
|
+
assert "451" in str(exc_info.value)
|
|
84
|
+
|
|
85
|
+
def test_repository_unavailable_error_with_malformed_json(self):
|
|
86
|
+
"""HTTP 451 with malformed JSON should still raise exception."""
|
|
87
|
+
args = Mock()
|
|
88
|
+
args.as_app = False
|
|
89
|
+
args.token_fine = None
|
|
90
|
+
args.token_classic = None
|
|
91
|
+
args.username = None
|
|
92
|
+
args.password = None
|
|
93
|
+
args.osx_keychain_item_name = None
|
|
94
|
+
args.osx_keychain_item_account = None
|
|
95
|
+
args.throttle_limit = None
|
|
96
|
+
args.throttle_pause = 0
|
|
97
|
+
|
|
98
|
+
mock_response = Mock()
|
|
99
|
+
mock_response.getcode.return_value = 451
|
|
100
|
+
mock_response.read.return_value = b"invalid json {"
|
|
101
|
+
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
102
|
+
mock_response.reason = "Unavailable For Legal Reasons"
|
|
103
|
+
|
|
104
|
+
def mock_get_response(request, auth, template):
|
|
105
|
+
return mock_response, []
|
|
106
|
+
|
|
107
|
+
with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
|
|
108
|
+
with pytest.raises(github_backup.RepositoryUnavailableError):
|
|
109
|
+
list(github_backup.retrieve_data_gen(args, "https://api.github.com/repos/test/dmca/issues"))
|
|
110
|
+
|
|
111
|
+
def test_other_http_errors_unchanged(self):
|
|
112
|
+
"""Other HTTP errors should still raise generic Exception."""
|
|
113
|
+
args = Mock()
|
|
114
|
+
args.as_app = False
|
|
115
|
+
args.token_fine = None
|
|
116
|
+
args.token_classic = None
|
|
117
|
+
args.username = None
|
|
118
|
+
args.password = None
|
|
119
|
+
args.osx_keychain_item_name = None
|
|
120
|
+
args.osx_keychain_item_account = None
|
|
121
|
+
args.throttle_limit = None
|
|
122
|
+
args.throttle_pause = 0
|
|
123
|
+
|
|
124
|
+
mock_response = Mock()
|
|
125
|
+
mock_response.getcode.return_value = 404
|
|
126
|
+
mock_response.read.return_value = b'{"message": "Not Found"}'
|
|
127
|
+
mock_response.headers = {"x-ratelimit-remaining": "5000"}
|
|
128
|
+
mock_response.reason = "Not Found"
|
|
129
|
+
|
|
130
|
+
def mock_get_response(request, auth, template):
|
|
131
|
+
return mock_response, []
|
|
132
|
+
|
|
133
|
+
with patch("github_backup.github_backup._get_response", side_effect=mock_get_response):
|
|
134
|
+
# Should raise generic Exception, not RepositoryUnavailableError
|
|
135
|
+
with pytest.raises(Exception) as exc_info:
|
|
136
|
+
list(github_backup.retrieve_data_gen(args, "https://api.github.com/repos/test/notfound/issues"))
|
|
137
|
+
|
|
138
|
+
assert not isinstance(exc_info.value, github_backup.RepositoryUnavailableError)
|
|
139
|
+
assert "404" in str(exc_info.value)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
pytest.main([__file__, "-v"])
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.51.3"
|
|
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
|