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.
Files changed (22) hide show
  1. {github_backup-0.51.3 → github_backup-0.52.0}/CHANGES.rst +82 -1
  2. {github_backup-0.51.3/github_backup.egg-info → github_backup-0.52.0}/PKG-INFO +1 -1
  3. github_backup-0.52.0/github_backup/__init__.py +1 -0
  4. {github_backup-0.51.3 → github_backup-0.52.0}/github_backup/github_backup.py +58 -29
  5. {github_backup-0.51.3 → github_backup-0.52.0/github_backup.egg-info}/PKG-INFO +1 -1
  6. {github_backup-0.51.3 → github_backup-0.52.0}/github_backup.egg-info/SOURCES.txt +1 -0
  7. {github_backup-0.51.3 → github_backup-0.52.0}/release-requirements.txt +4 -4
  8. github_backup-0.52.0/tests/test_http_451.py +143 -0
  9. github_backup-0.51.3/github_backup/__init__.py +0 -1
  10. {github_backup-0.51.3 → github_backup-0.52.0}/LICENSE.txt +0 -0
  11. {github_backup-0.51.3 → github_backup-0.52.0}/MANIFEST.in +0 -0
  12. {github_backup-0.51.3 → github_backup-0.52.0}/README +0 -0
  13. {github_backup-0.51.3 → github_backup-0.52.0}/README.rst +0 -0
  14. {github_backup-0.51.3 → github_backup-0.52.0}/bin/github-backup +0 -0
  15. {github_backup-0.51.3 → github_backup-0.52.0}/github_backup.egg-info/dependency_links.txt +0 -0
  16. {github_backup-0.51.3 → github_backup-0.52.0}/github_backup.egg-info/top_level.txt +0 -0
  17. {github_backup-0.51.3 → github_backup-0.52.0}/github_backup.egg-info/zip-safe +0 -0
  18. {github_backup-0.51.3 → github_backup-0.52.0}/requirements.txt +0 -0
  19. {github_backup-0.51.3 → github_backup-0.52.0}/setup.cfg +0 -0
  20. {github_backup-0.51.3 → github_backup-0.52.0}/setup.py +0 -0
  21. {github_backup-0.51.3 → github_backup-0.52.0}/tests/test_attachments.py +0 -0
  22. {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.51.3 (2025-11-18)
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]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.51.3
3
+ Version: 0.52.0
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.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
- download_wiki = args.include_wiki or args.include_everything
1672
- if repository["has_wiki"] and download_wiki:
1673
- fetch_repository(
1674
- repository["name"],
1675
- repo_url.replace(".git", ".wiki.git"),
1676
- os.path.join(repo_cwd, "wiki"),
1677
- skip_existing=args.skip_existing,
1678
- bare_clone=args.bare_clone,
1679
- lfs_clone=args.lfs_clone,
1680
- no_prune=args.no_prune,
1681
- )
1682
- if args.include_issues or args.include_everything:
1683
- backup_issues(args, repo_cwd, repository, repos_template)
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
- if args.include_pulls or args.include_everything:
1686
- backup_pulls(args, repo_cwd, repository, repos_template)
1708
+ if args.include_pulls or args.include_everything:
1709
+ backup_pulls(args, repo_cwd, repository, repos_template)
1687
1710
 
1688
- if args.include_milestones or args.include_everything:
1689
- backup_milestones(args, repo_cwd, repository, repos_template)
1711
+ if args.include_milestones or args.include_everything:
1712
+ backup_milestones(args, repo_cwd, repository, repos_template)
1690
1713
 
1691
- if args.include_labels or args.include_everything:
1692
- backup_labels(args, repo_cwd, repository, repos_template)
1714
+ if args.include_labels or args.include_everything:
1715
+ backup_labels(args, repo_cwd, repository, repos_template)
1693
1716
 
1694
- if args.include_hooks or args.include_everything:
1695
- backup_hooks(args, repo_cwd, repository, repos_template)
1717
+ if args.include_hooks or args.include_everything:
1718
+ backup_hooks(args, repo_cwd, repository, repos_template)
1696
1719
 
1697
- if args.include_releases or args.include_everything:
1698
- backup_releases(
1699
- args,
1700
- repo_cwd,
1701
- repository,
1702
- repos_template,
1703
- include_assets=args.include_assets or args.include_everything,
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":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.51.3
3
+ Version: 0.52.0
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
@@ -15,4 +15,5 @@ github_backup.egg-info/dependency_links.txt
15
15
  github_backup.egg-info/top_level.txt
16
16
  github_backup.egg-info/zip-safe
17
17
  tests/test_attachments.py
18
+ tests/test_http_451.py
18
19
  tests/test_pagination.py
@@ -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.0
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==8.3.3
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.6.0
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==1.4.0
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