github-backup 0.64.0__tar.gz → 0.64.2__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 (40) hide show
  1. {github_backup-0.64.0 → github_backup-0.64.2}/CHANGES.rst +13 -1
  2. {github_backup-0.64.0/github_backup.egg-info → github_backup-0.64.2}/PKG-INFO +1 -1
  3. github_backup-0.64.2/github_backup/__init__.py +1 -0
  4. {github_backup-0.64.0 → github_backup-0.64.2}/github_backup/github_backup.py +82 -10
  5. {github_backup-0.64.0 → github_backup-0.64.2/github_backup.egg-info}/PKG-INFO +1 -1
  6. {github_backup-0.64.0 → github_backup-0.64.2}/github_backup.egg-info/SOURCES.txt +1 -0
  7. github_backup-0.64.2/tests/test_gists.py +227 -0
  8. github_backup-0.64.0/github_backup/__init__.py +0 -1
  9. {github_backup-0.64.0 → github_backup-0.64.2}/LICENSE.txt +0 -0
  10. {github_backup-0.64.0 → github_backup-0.64.2}/MANIFEST.in +0 -0
  11. {github_backup-0.64.0 → github_backup-0.64.2}/README +0 -0
  12. {github_backup-0.64.0 → github_backup-0.64.2}/README.rst +0 -0
  13. {github_backup-0.64.0 → github_backup-0.64.2}/github_backup/__main__.py +0 -0
  14. {github_backup-0.64.0 → github_backup-0.64.2}/github_backup/cli.py +0 -0
  15. {github_backup-0.64.0 → github_backup-0.64.2}/github_backup/graphql_queries.py +0 -0
  16. {github_backup-0.64.0 → github_backup-0.64.2}/github_backup.egg-info/dependency_links.txt +0 -0
  17. {github_backup-0.64.0 → github_backup-0.64.2}/github_backup.egg-info/entry_points.txt +0 -0
  18. {github_backup-0.64.0 → github_backup-0.64.2}/github_backup.egg-info/top_level.txt +0 -0
  19. {github_backup-0.64.0 → github_backup-0.64.2}/github_backup.egg-info/zip-safe +0 -0
  20. {github_backup-0.64.0 → github_backup-0.64.2}/release-requirements.txt +0 -0
  21. {github_backup-0.64.0 → github_backup-0.64.2}/requirements.txt +0 -0
  22. {github_backup-0.64.0 → github_backup-0.64.2}/setup.cfg +0 -0
  23. {github_backup-0.64.0 → github_backup-0.64.2}/setup.py +0 -0
  24. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_all_starred.py +0 -0
  25. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_attachments.py +0 -0
  26. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_auth.py +0 -0
  27. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_case_sensitivity.py +0 -0
  28. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_disabled_issues_pulls.py +0 -0
  29. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_discussions.py +0 -0
  30. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_http_451.py +0 -0
  31. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_incremental_per_repository.py +0 -0
  32. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_json_dump_if_changed.py +0 -0
  33. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_logging_subprocess.py +0 -0
  34. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_pagination.py +0 -0
  35. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_pull_incremental_pagination.py +0 -0
  36. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_pull_reviews.py +0 -0
  37. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_releases.py +0 -0
  38. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_retrieve_data.py +0 -0
  39. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_skip_assets_on.py +0 -0
  40. {github_backup-0.64.0 → github_backup-0.64.2}/tests/test_starred_skip_size_over.py +0 -0
@@ -1,9 +1,21 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.64.0 (2026-07-07)
4
+ 0.64.2 (2026-07-18)
5
5
  -------------------
6
6
  ------------------------
7
+ - Add tests. [Duncan Ogilvie]
8
+ - Skip fetching gists that have not been updated. [Duncan Ogilvie]
9
+ - Skip retrieving repositories when not necessary. [Duncan Ogilvie]
10
+
11
+
12
+ 0.64.1 (2026-07-17)
13
+ -------------------
14
+ - Fix exception when DMCA'd/empty gists were backed up. [Duncan Ogilvie]
15
+
16
+
17
+ 0.64.0 (2026-07-07)
18
+ -------------------
7
19
 
8
20
  Fix
9
21
  ~~~
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.64.0
3
+ Version: 0.64.2
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.64.2"
@@ -1789,7 +1789,39 @@ def check_git_lfs_install():
1789
1789
  )
1790
1790
 
1791
1791
 
1792
+ # Resources fetched from their own endpoints, not from the repository listing
1793
+ # (gists and account-level data). Every other include_* flag needs the listing.
1794
+ NON_REPOSITORY_RESOURCES = frozenset(
1795
+ {
1796
+ "include_gists",
1797
+ "include_starred_gists",
1798
+ "include_starred",
1799
+ "include_watched",
1800
+ "include_followers",
1801
+ "include_following",
1802
+ }
1803
+ )
1804
+
1805
+
1806
+ def repository_list_needed(args):
1807
+ """The base repository listing is only needed when a per-repository
1808
+ resource was requested; e.g. a gists-only backup can skip it entirely.
1809
+ Flags not listed in NON_REPOSITORY_RESOURCES default to needing it, so a
1810
+ newly added resource can never be silently skipped."""
1811
+ if args.repository:
1812
+ return True
1813
+ return any(
1814
+ value
1815
+ for name, value in vars(args).items()
1816
+ if name.startswith("include_") and name not in NON_REPOSITORY_RESOURCES
1817
+ )
1818
+
1819
+
1792
1820
  def retrieve_repositories(args, authenticated_user):
1821
+ if not repository_list_needed(args):
1822
+ repos = []
1823
+ return retrieve_additional_repositories(args, authenticated_user, repos)
1824
+
1793
1825
  logger.info("Retrieving repositories")
1794
1826
  paginated = True
1795
1827
  if args.user == authenticated_user["login"]:
@@ -1825,6 +1857,12 @@ def retrieve_repositories(args, authenticated_user):
1825
1857
  logger.warning(f"Legal notice: {e.legal_url}")
1826
1858
  return []
1827
1859
 
1860
+ return retrieve_additional_repositories(args, authenticated_user, repos)
1861
+
1862
+
1863
+ def retrieve_additional_repositories(args, authenticated_user, repos):
1864
+ """Append starred repositories, gists and starred gists to the base
1865
+ repository list when requested."""
1828
1866
  if args.all_starred:
1829
1867
  starred_template = "https://{0}/users/{1}/starred".format(
1830
1868
  get_github_api_host(args), args.user
@@ -2058,6 +2096,24 @@ def remove_legacy_last_update_if_migrated(
2058
2096
  )
2059
2097
 
2060
2098
 
2099
+ def gist_backup_is_current(repository, repo_cwd, repo_dir):
2100
+ """Whether fetching a gist can be skipped because a clone exists and the
2101
+ stored gist.json matches the listing's updated_at. Any edit to a gist —
2102
+ including a git push — bumps updated_at, so this can never miss a content
2103
+ change; at worst a comment bumps it and causes a harmless refetch."""
2104
+ if not os.path.exists(repo_dir):
2105
+ return False
2106
+ updated_at = repository.get("updated_at")
2107
+ if not updated_at:
2108
+ return False
2109
+ try:
2110
+ with codecs.open(os.path.join(repo_cwd, "gist.json"), encoding="utf-8") as f:
2111
+ stored = json.load(f)
2112
+ except (OSError, ValueError):
2113
+ return False
2114
+ return stored.get("updated_at") == updated_at
2115
+
2116
+
2061
2117
  def backup_repositories(args, output_directory, repositories):
2062
2118
  logger.info("Backing up repositories")
2063
2119
  repos_template = "https://{0}/repos".format(get_github_api_host(args))
@@ -2065,6 +2121,7 @@ def backup_repositories(args, output_directory, repositories):
2065
2121
  args, output_directory
2066
2122
  )
2067
2123
  incremental_resource_work_attempted = False
2124
+ skipped_unchanged_gists = 0
2068
2125
 
2069
2126
  for repository in repositories:
2070
2127
  if repository.get("is_gist"):
@@ -2098,18 +2155,30 @@ def backup_repositories(args, output_directory, repositories):
2098
2155
  if not repository.get("is_gist")
2099
2156
  else repository.get("id")
2100
2157
  )
2101
- fetch_repository(
2102
- repo_name,
2103
- repo_url,
2104
- repo_dir,
2105
- skip_existing=args.skip_existing,
2106
- bare_clone=args.bare_clone,
2107
- lfs_clone=args.lfs_clone,
2108
- no_prune=args.no_prune,
2109
- )
2158
+ if repository.get("is_gist") and gist_backup_is_current(
2159
+ repository, repo_cwd, repo_dir
2160
+ ):
2161
+ logger.debug(
2162
+ "Skipping gist {0} (unchanged since last backup)".format(repo_name)
2163
+ )
2164
+ skipped_unchanged_gists += 1
2165
+ else:
2166
+ fetch_repository(
2167
+ repo_name,
2168
+ repo_url,
2169
+ repo_dir,
2170
+ skip_existing=args.skip_existing,
2171
+ bare_clone=args.bare_clone,
2172
+ lfs_clone=args.lfs_clone,
2173
+ no_prune=args.no_prune,
2174
+ )
2110
2175
 
2111
2176
  if repository.get("is_gist"):
2112
- # dump gist information to a file as well
2177
+ # dump gist information to a file as well, even when the fetch
2178
+ # was skipped, so listing metadata (fork counts etc.) stays
2179
+ # fresh; the clone may also have been skipped entirely (e.g.
2180
+ # DMCA-blocked or empty gist), so make sure the directory exists
2181
+ mkdir_p(repo_cwd)
2113
2182
  output_file = "{0}/gist.json".format(repo_cwd)
2114
2183
  with codecs.open(output_file, "w", encoding="utf-8") as f:
2115
2184
  json_dump(repository, f)
@@ -2178,6 +2247,9 @@ def backup_repositories(args, output_directory, repositories):
2178
2247
  logger.info(f"Skipping remaining resources for {repository['full_name']}")
2179
2248
  continue
2180
2249
 
2250
+ if skipped_unchanged_gists:
2251
+ logger.info("Skipped {0} unchanged gists".format(skipped_unchanged_gists))
2252
+
2181
2253
  if incremental_resource_work_attempted:
2182
2254
  remove_legacy_last_update_if_migrated(
2183
2255
  args, output_directory, legacy_last_update_path
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.64.0
3
+ Version: 0.64.2
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
@@ -23,6 +23,7 @@ tests/test_auth.py
23
23
  tests/test_case_sensitivity.py
24
24
  tests/test_disabled_issues_pulls.py
25
25
  tests/test_discussions.py
26
+ tests/test_gists.py
26
27
  tests/test_http_451.py
27
28
  tests/test_incremental_per_repository.py
28
29
  tests/test_json_dump_if_changed.py
@@ -0,0 +1,227 @@
1
+ """Tests for gist backup behavior: skipping the repository listing for
2
+ gists-only backups, skipping fetches for unchanged gists, and writing
3
+ gist.json even when the clone was skipped (e.g. DMCA-blocked gists)."""
4
+
5
+ import json
6
+ import os
7
+
8
+ import pytest
9
+ from unittest.mock import patch
10
+
11
+ from github_backup.github_backup import (
12
+ backup_repositories,
13
+ gist_backup_is_current,
14
+ repository_list_needed,
15
+ retrieve_repositories,
16
+ )
17
+
18
+
19
+ class TestRepositoryListNeeded:
20
+ """The base /user/repos listing should only be fetched when a
21
+ per-repository resource was requested."""
22
+
23
+ def test_gists_only_does_not_need_listing(self, create_args):
24
+ args = create_args(include_gists=True, include_starred_gists=True)
25
+ assert not repository_list_needed(args)
26
+
27
+ def test_account_level_flags_do_not_need_listing(self, create_args):
28
+ args = create_args(
29
+ include_starred=True,
30
+ include_watched=True,
31
+ include_followers=True,
32
+ include_following=True,
33
+ )
34
+ assert not repository_list_needed(args)
35
+
36
+ def test_all_starred_does_not_need_listing(self, create_args):
37
+ # starred repos come from /users/{user}/starred, not /user/repos
38
+ args = create_args(all_starred=True)
39
+ assert not repository_list_needed(args)
40
+
41
+ @pytest.mark.parametrize(
42
+ "flag",
43
+ [
44
+ "include_repository",
45
+ "include_everything",
46
+ "include_wiki",
47
+ "include_issues",
48
+ "include_pulls",
49
+ "include_releases",
50
+ "include_attachments",
51
+ ],
52
+ )
53
+ def test_repository_scoped_flags_need_listing(self, create_args, flag):
54
+ args = create_args(**{flag: True})
55
+ assert repository_list_needed(args)
56
+
57
+ def test_single_repository_needs_listing(self, create_args):
58
+ args = create_args(repository="some-repo")
59
+ assert repository_list_needed(args)
60
+
61
+ def test_unknown_include_flag_defaults_to_needing_listing(self, create_args):
62
+ # Fail-safe: a future include_* flag that nobody registered in
63
+ # NON_REPOSITORY_RESOURCES must not silently skip the listing.
64
+ args = create_args(include_some_future_resource=True)
65
+ assert repository_list_needed(args)
66
+
67
+
68
+ class TestRetrieveRepositoriesSkipsListing:
69
+ """retrieve_repositories should not hit the repository endpoints for a
70
+ gists-only backup, but must still fetch gists and starred gists."""
71
+
72
+ @patch("github_backup.github_backup.retrieve_data")
73
+ def test_gists_only_skips_repo_listing(self, mock_retrieve, create_args):
74
+ args = create_args(include_gists=True, include_starred_gists=True)
75
+ mock_retrieve.return_value = [{"id": "abc123"}]
76
+
77
+ repos = retrieve_repositories(args, {"login": "testuser"})
78
+
79
+ requested = [call.args[1] for call in mock_retrieve.call_args_list]
80
+ assert not any("/repos" in url for url in requested), requested
81
+ assert any(url.endswith("/users/testuser/gists") for url in requested)
82
+ assert any(url.endswith("/gists/starred") for url in requested)
83
+ assert all(r["is_gist"] for r in repos)
84
+
85
+ @patch("github_backup.github_backup.retrieve_data")
86
+ def test_repository_backup_still_fetches_listing(self, mock_retrieve, create_args):
87
+ args = create_args(include_repository=True)
88
+ mock_retrieve.return_value = []
89
+
90
+ retrieve_repositories(args, {"login": "testuser"})
91
+
92
+ requested = [call.args[1] for call in mock_retrieve.call_args_list]
93
+ assert any(url.endswith("/user/repos") for url in requested), requested
94
+
95
+
96
+ class TestGistBackupIsCurrent:
97
+ """A gist fetch is skipped only when a clone exists and the stored
98
+ gist.json updated_at matches the listing."""
99
+
100
+ GIST = {"id": "abc123", "is_gist": True, "updated_at": "2026-07-17T10:00:00Z"}
101
+
102
+ def _write_backup(self, repo_cwd, updated_at="2026-07-17T10:00:00Z", clone=True):
103
+ if clone:
104
+ os.makedirs(os.path.join(repo_cwd, "repository"))
105
+ else:
106
+ os.makedirs(repo_cwd)
107
+ with open(os.path.join(repo_cwd, "gist.json"), "w") as f:
108
+ json.dump({"id": "abc123", "updated_at": updated_at}, f)
109
+
110
+ def test_current_when_unchanged(self, tmp_path):
111
+ repo_cwd = str(tmp_path / "gists" / "abc123")
112
+ self._write_backup(repo_cwd)
113
+ assert gist_backup_is_current(
114
+ self.GIST, repo_cwd, os.path.join(repo_cwd, "repository")
115
+ )
116
+
117
+ def test_not_current_when_updated(self, tmp_path):
118
+ repo_cwd = str(tmp_path / "gists" / "abc123")
119
+ self._write_backup(repo_cwd, updated_at="2020-01-01T00:00:00Z")
120
+ assert not gist_backup_is_current(
121
+ self.GIST, repo_cwd, os.path.join(repo_cwd, "repository")
122
+ )
123
+
124
+ def test_not_current_without_clone(self, tmp_path):
125
+ # gist.json alone is not enough; e.g. the clone was skipped because
126
+ # the gist was inaccessible, so keep retrying the fetch
127
+ repo_cwd = str(tmp_path / "gists" / "abc123")
128
+ self._write_backup(repo_cwd, clone=False)
129
+ assert not gist_backup_is_current(
130
+ self.GIST, repo_cwd, os.path.join(repo_cwd, "repository")
131
+ )
132
+
133
+ def test_not_current_with_corrupt_gist_json(self, tmp_path):
134
+ repo_cwd = str(tmp_path / "gists" / "abc123")
135
+ os.makedirs(os.path.join(repo_cwd, "repository"))
136
+ with open(os.path.join(repo_cwd, "gist.json"), "w") as f:
137
+ f.write("{not json")
138
+ assert not gist_backup_is_current(
139
+ self.GIST, repo_cwd, os.path.join(repo_cwd, "repository")
140
+ )
141
+
142
+ def test_not_current_without_updated_at(self, tmp_path):
143
+ repo_cwd = str(tmp_path / "gists" / "abc123")
144
+ self._write_backup(repo_cwd)
145
+ assert not gist_backup_is_current(
146
+ {"id": "abc123", "is_gist": True},
147
+ repo_cwd,
148
+ os.path.join(repo_cwd, "repository"),
149
+ )
150
+
151
+
152
+ class TestGistBackup:
153
+ """End-to-end behavior of backup_repositories for gists."""
154
+
155
+ def _gist(self, **extra):
156
+ gist = {
157
+ "id": "abc123",
158
+ "is_gist": True,
159
+ "updated_at": "2026-07-17T10:00:00Z",
160
+ "git_pull_url": "https://gist.github.com/abc123.git",
161
+ }
162
+ gist.update(extra)
163
+ return gist
164
+
165
+ @patch("github_backup.github_backup.fetch_repository")
166
+ def test_new_gist_is_fetched_and_metadata_written(
167
+ self, mock_fetch, create_args, tmp_path
168
+ ):
169
+ args = create_args(include_gists=True)
170
+
171
+ backup_repositories(args, str(tmp_path), [self._gist()])
172
+
173
+ mock_fetch.assert_called_once()
174
+ gist_json = tmp_path / "gists" / "abc123" / "gist.json"
175
+ assert json.loads(gist_json.read_text())["id"] == "abc123"
176
+
177
+ @patch("github_backup.github_backup.fetch_repository")
178
+ def test_unchanged_gist_skips_fetch_but_refreshes_metadata(
179
+ self, mock_fetch, create_args, tmp_path
180
+ ):
181
+ args = create_args(include_gists=True)
182
+ repo_cwd = tmp_path / "gists" / "abc123"
183
+ (repo_cwd / "repository").mkdir(parents=True)
184
+ (repo_cwd / "gist.json").write_text(
185
+ json.dumps({"id": "abc123", "updated_at": "2026-07-17T10:00:00Z"})
186
+ )
187
+
188
+ backup_repositories(args, str(tmp_path), [self._gist(forks=5)])
189
+
190
+ assert not mock_fetch.called, "unchanged gist should not be fetched"
191
+ # gist.json is rewritten from the listing so metadata that changes
192
+ # without bumping updated_at (fork counts etc.) stays fresh
193
+ assert json.loads((repo_cwd / "gist.json").read_text())["forks"] == 5
194
+
195
+ @patch("github_backup.github_backup.fetch_repository")
196
+ def test_updated_gist_is_fetched(self, mock_fetch, create_args, tmp_path):
197
+ args = create_args(include_gists=True)
198
+ repo_cwd = tmp_path / "gists" / "abc123"
199
+ (repo_cwd / "repository").mkdir(parents=True)
200
+ (repo_cwd / "gist.json").write_text(
201
+ json.dumps({"id": "abc123", "updated_at": "2020-01-01T00:00:00Z"})
202
+ )
203
+
204
+ backup_repositories(args, str(tmp_path), [self._gist()])
205
+
206
+ mock_fetch.assert_called_once()
207
+
208
+ @patch("github_backup.github_backup.fetch_repository")
209
+ def test_inaccessible_gist_does_not_crash_backup(
210
+ self, mock_fetch, create_args, tmp_path
211
+ ):
212
+ """Regression test: fetch_repository skips inaccessible gists (e.g.
213
+ DMCA-blocked) without creating the clone directory; writing gist.json
214
+ must not crash and the next gist must still be processed."""
215
+ args = create_args(include_gists=True)
216
+ # mock fetch never creates the directory, like a skipped clone
217
+ blocked = self._gist(id="deadbeef")
218
+ second = self._gist()
219
+
220
+ backup_repositories(args, str(tmp_path), [blocked, second])
221
+
222
+ assert (tmp_path / "gists" / "deadbeef" / "gist.json").is_file()
223
+ assert (tmp_path / "gists" / "abc123" / "gist.json").is_file()
224
+
225
+
226
+ if __name__ == "__main__":
227
+ pytest.main([__file__, "-v"])
@@ -1 +0,0 @@
1
- __version__ = "0.64.0"
File without changes
File without changes
File without changes