github-backup 0.52.0__tar.gz → 0.53.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 (23) hide show
  1. {github_backup-0.52.0 → github_backup-0.53.0}/CHANGES.rst +36 -1
  2. {github_backup-0.52.0/github_backup.egg-info → github_backup-0.53.0}/PKG-INFO +1 -1
  3. github_backup-0.53.0/github_backup/__init__.py +1 -0
  4. {github_backup-0.52.0 → github_backup-0.53.0}/github_backup/github_backup.py +88 -12
  5. {github_backup-0.52.0 → github_backup-0.53.0/github_backup.egg-info}/PKG-INFO +1 -1
  6. {github_backup-0.52.0 → github_backup-0.53.0}/github_backup.egg-info/SOURCES.txt +1 -0
  7. github_backup-0.53.0/tests/test_json_dump_if_changed.py +198 -0
  8. github_backup-0.52.0/github_backup/__init__.py +0 -1
  9. {github_backup-0.52.0 → github_backup-0.53.0}/LICENSE.txt +0 -0
  10. {github_backup-0.52.0 → github_backup-0.53.0}/MANIFEST.in +0 -0
  11. {github_backup-0.52.0 → github_backup-0.53.0}/README +0 -0
  12. {github_backup-0.52.0 → github_backup-0.53.0}/README.rst +0 -0
  13. {github_backup-0.52.0 → github_backup-0.53.0}/bin/github-backup +0 -0
  14. {github_backup-0.52.0 → github_backup-0.53.0}/github_backup.egg-info/dependency_links.txt +0 -0
  15. {github_backup-0.52.0 → github_backup-0.53.0}/github_backup.egg-info/top_level.txt +0 -0
  16. {github_backup-0.52.0 → github_backup-0.53.0}/github_backup.egg-info/zip-safe +0 -0
  17. {github_backup-0.52.0 → github_backup-0.53.0}/release-requirements.txt +0 -0
  18. {github_backup-0.52.0 → github_backup-0.53.0}/requirements.txt +0 -0
  19. {github_backup-0.52.0 → github_backup-0.53.0}/setup.cfg +0 -0
  20. {github_backup-0.52.0 → github_backup-0.53.0}/setup.py +0 -0
  21. {github_backup-0.52.0 → github_backup-0.53.0}/tests/test_attachments.py +0 -0
  22. {github_backup-0.52.0 → github_backup-0.53.0}/tests/test_http_451.py +0 -0
  23. {github_backup-0.52.0 → github_backup-0.53.0}/tests/test_pagination.py +0 -0
@@ -1,9 +1,44 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.52.0 (2025-11-28)
4
+ 0.53.0 (2025-11-30)
5
5
  -------------------
6
6
  ------------------------
7
+
8
+ Fix
9
+ ~~~
10
+ - Case-sensitive username filtering causing silent backup failures.
11
+ [Rodos]
12
+
13
+ GitHub's API accepts usernames in any case but returns canonical case.
14
+ The case-sensitive comparison in filter_repositories() filtered out all
15
+ repositories when user-provided case didn't match GitHub's canonical case.
16
+
17
+ Changed to case-insensitive comparison.
18
+
19
+ Fixes #198
20
+
21
+ Other
22
+ ~~~~~
23
+ - Avoid rewriting unchanged JSON files for labels, milestones, releases,
24
+ hooks, followers, and following. [Rodos]
25
+
26
+ This change reduces unnecessary writes when backing up metadata that changes
27
+ infrequently. The implementation compares existing file content before writing
28
+ and skips the write if the content is identical, preserving file timestamps.
29
+
30
+ Key changes:
31
+ - Added json_dump_if_changed() helper that compares content before writing
32
+ - Uses atomic writes (temp file + rename) for all metadata files
33
+ - NOT applied to issues/pulls (they use incremental_by_files logic)
34
+ - Made log messages consistent and past tense ("Saved" instead of "Saving")
35
+ - Added informative logging showing skip counts
36
+
37
+ Fixes #133
38
+
39
+
40
+ 0.52.0 (2025-11-28)
41
+ -------------------
7
42
  - Skip DMCA'd repos which return a 451 response. [Rodos]
8
43
 
9
44
  Log a warning and the link to the DMCA notice. Continue backing up
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.52.0
3
+ Version: 0.53.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.53.0"
@@ -1587,7 +1587,9 @@ def filter_repositories(args, unfiltered_repositories):
1587
1587
  repositories = []
1588
1588
  for r in unfiltered_repositories:
1589
1589
  # gists can be anonymous, so need to safely check owner
1590
- if r.get("owner", {}).get("login") == args.user or r.get("is_starred"):
1590
+ # Use case-insensitive comparison to match GitHub's case-insensitive username behavior
1591
+ owner_login = r.get("owner", {}).get("login", "")
1592
+ if owner_login.lower() == args.user.lower() or r.get("is_starred"):
1591
1593
  repositories.append(r)
1592
1594
 
1593
1595
  name_regex = None
@@ -1898,11 +1900,21 @@ def backup_milestones(args, repo_cwd, repository, repos_template):
1898
1900
  for milestone in _milestones:
1899
1901
  milestones[milestone["number"]] = milestone
1900
1902
 
1901
- logger.info("Saving {0} milestones to disk".format(len(list(milestones.keys()))))
1903
+ written_count = 0
1902
1904
  for number, milestone in list(milestones.items()):
1903
1905
  milestone_file = "{0}/{1}.json".format(milestone_cwd, number)
1904
- with codecs.open(milestone_file, "w", encoding="utf-8") as f:
1905
- json_dump(milestone, f)
1906
+ if json_dump_if_changed(milestone, milestone_file):
1907
+ written_count += 1
1908
+
1909
+ total = len(milestones)
1910
+ if written_count == total:
1911
+ logger.info("Saved {0} milestones to disk".format(total))
1912
+ elif written_count == 0:
1913
+ logger.info("{0} milestones unchanged, skipped write".format(total))
1914
+ else:
1915
+ logger.info("Saved {0} of {1} milestones to disk ({2} unchanged)".format(
1916
+ written_count, total, total - written_count
1917
+ ))
1906
1918
 
1907
1919
 
1908
1920
  def backup_labels(args, repo_cwd, repository, repos_template):
@@ -1955,19 +1967,17 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
1955
1967
  reverse=True,
1956
1968
  )
1957
1969
  releases = releases[: args.number_of_latest_releases]
1958
- logger.info("Saving the latest {0} releases to disk".format(len(releases)))
1959
- else:
1960
- logger.info("Saving {0} releases to disk".format(len(releases)))
1961
1970
 
1962
1971
  # for each release, store it
1972
+ written_count = 0
1963
1973
  for release in releases:
1964
1974
  release_name = release["tag_name"]
1965
1975
  release_name_safe = release_name.replace("/", "__")
1966
1976
  output_filepath = os.path.join(
1967
1977
  release_cwd, "{0}.json".format(release_name_safe)
1968
1978
  )
1969
- with codecs.open(output_filepath, "w+", encoding="utf-8") as f:
1970
- json_dump(release, f)
1979
+ if json_dump_if_changed(release, output_filepath):
1980
+ written_count += 1
1971
1981
 
1972
1982
  if include_assets:
1973
1983
  assets = retrieve_data(args, release["assets_url"])
@@ -1984,6 +1994,17 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
1984
1994
  fine=True if args.token_fine is not None else False,
1985
1995
  )
1986
1996
 
1997
+ # Log the results
1998
+ total = len(releases)
1999
+ if written_count == total:
2000
+ logger.info("Saved {0} releases to disk".format(total))
2001
+ elif written_count == 0:
2002
+ logger.info("{0} releases unchanged, skipped write".format(total))
2003
+ else:
2004
+ logger.info("Saved {0} of {1} releases to disk ({2} unchanged)".format(
2005
+ written_count, total, total - written_count
2006
+ ))
2007
+
1987
2008
 
1988
2009
  def fetch_repository(
1989
2010
  name,
@@ -2108,9 +2129,10 @@ def _backup_data(args, name, template, output_file, output_directory):
2108
2129
  mkdir_p(output_directory)
2109
2130
  data = retrieve_data(args, template)
2110
2131
 
2111
- logger.info("Writing {0} {1} to disk".format(len(data), name))
2112
- with codecs.open(output_file, "w", encoding="utf-8") as f:
2113
- json_dump(data, f)
2132
+ if json_dump_if_changed(data, output_file):
2133
+ logger.info("Saved {0} {1} to disk".format(len(data), name))
2134
+ else:
2135
+ logger.info("{0} {1} unchanged, skipped write".format(len(data), name))
2114
2136
 
2115
2137
 
2116
2138
  def json_dump(data, output_file):
@@ -2122,3 +2144,57 @@ def json_dump(data, output_file):
2122
2144
  indent=4,
2123
2145
  separators=(",", ": "),
2124
2146
  )
2147
+
2148
+
2149
+ def json_dump_if_changed(data, output_file_path):
2150
+ """
2151
+ Write JSON data to file only if content has changed.
2152
+
2153
+ Compares the serialized JSON data with the existing file content
2154
+ and only writes if different. This prevents unnecessary file
2155
+ modification timestamp updates and disk writes.
2156
+
2157
+ Uses atomic writes (temp file + rename) to prevent corruption
2158
+ if the process is interrupted during the write.
2159
+
2160
+ Args:
2161
+ data: The data to serialize as JSON
2162
+ output_file_path: The path to the output file
2163
+
2164
+ Returns:
2165
+ True if file was written (content changed or new file)
2166
+ False if write was skipped (content unchanged)
2167
+ """
2168
+ # Serialize new data with consistent formatting matching json_dump()
2169
+ new_content = json.dumps(
2170
+ data,
2171
+ ensure_ascii=False,
2172
+ sort_keys=True,
2173
+ indent=4,
2174
+ separators=(",", ": "),
2175
+ )
2176
+
2177
+ # Check if file exists and compare content
2178
+ if os.path.exists(output_file_path):
2179
+ try:
2180
+ with codecs.open(output_file_path, "r", encoding="utf-8") as f:
2181
+ existing_content = f.read()
2182
+ if existing_content == new_content:
2183
+ logger.debug(
2184
+ "Content unchanged, skipping write: {0}".format(output_file_path)
2185
+ )
2186
+ return False
2187
+ except (OSError, UnicodeDecodeError) as e:
2188
+ # If we can't read the existing file, write the new one
2189
+ logger.debug(
2190
+ "Error reading existing file {0}, will overwrite: {1}".format(
2191
+ output_file_path, e
2192
+ )
2193
+ )
2194
+
2195
+ # Write the file atomically using temp file + rename
2196
+ temp_file = output_file_path + ".temp"
2197
+ with codecs.open(temp_file, "w", encoding="utf-8") as f:
2198
+ f.write(new_content)
2199
+ os.rename(temp_file, output_file_path) # Atomic on POSIX systems
2200
+ return True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.52.0
3
+ Version: 0.53.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
@@ -16,4 +16,5 @@ github_backup.egg-info/top_level.txt
16
16
  github_backup.egg-info/zip-safe
17
17
  tests/test_attachments.py
18
18
  tests/test_http_451.py
19
+ tests/test_json_dump_if_changed.py
19
20
  tests/test_pagination.py
@@ -0,0 +1,198 @@
1
+ """Tests for json_dump_if_changed functionality."""
2
+
3
+ import codecs
4
+ import json
5
+ import os
6
+ import tempfile
7
+
8
+ import pytest
9
+
10
+ from github_backup import github_backup
11
+
12
+
13
+ class TestJsonDumpIfChanged:
14
+ """Test suite for json_dump_if_changed function."""
15
+
16
+ def test_writes_new_file(self):
17
+ """Should write file when it doesn't exist."""
18
+ with tempfile.TemporaryDirectory() as tmpdir:
19
+ output_file = os.path.join(tmpdir, "test.json")
20
+ test_data = {"key": "value", "number": 42}
21
+
22
+ result = github_backup.json_dump_if_changed(test_data, output_file)
23
+
24
+ assert result is True
25
+ assert os.path.exists(output_file)
26
+
27
+ # Verify content matches expected format
28
+ with codecs.open(output_file, "r", encoding="utf-8") as f:
29
+ content = f.read()
30
+ loaded = json.loads(content)
31
+ assert loaded == test_data
32
+
33
+ def test_skips_unchanged_file(self):
34
+ """Should skip write when content is identical."""
35
+ with tempfile.TemporaryDirectory() as tmpdir:
36
+ output_file = os.path.join(tmpdir, "test.json")
37
+ test_data = {"key": "value", "number": 42}
38
+
39
+ # First write
40
+ result1 = github_backup.json_dump_if_changed(test_data, output_file)
41
+ assert result1 is True
42
+
43
+ # Get the initial mtime
44
+ mtime1 = os.path.getmtime(output_file)
45
+
46
+ # Second write with same data
47
+ result2 = github_backup.json_dump_if_changed(test_data, output_file)
48
+ assert result2 is False
49
+
50
+ # File should not have been modified
51
+ mtime2 = os.path.getmtime(output_file)
52
+ assert mtime1 == mtime2
53
+
54
+ def test_writes_when_content_changed(self):
55
+ """Should write file when content has changed."""
56
+ with tempfile.TemporaryDirectory() as tmpdir:
57
+ output_file = os.path.join(tmpdir, "test.json")
58
+ test_data1 = {"key": "value1"}
59
+ test_data2 = {"key": "value2"}
60
+
61
+ # First write
62
+ result1 = github_backup.json_dump_if_changed(test_data1, output_file)
63
+ assert result1 is True
64
+
65
+ # Second write with different data
66
+ result2 = github_backup.json_dump_if_changed(test_data2, output_file)
67
+ assert result2 is True
68
+
69
+ # Verify new content
70
+ with codecs.open(output_file, "r", encoding="utf-8") as f:
71
+ loaded = json.load(f)
72
+ assert loaded == test_data2
73
+
74
+ def test_uses_consistent_formatting(self):
75
+ """Should use same JSON formatting as json_dump."""
76
+ with tempfile.TemporaryDirectory() as tmpdir:
77
+ output_file = os.path.join(tmpdir, "test.json")
78
+ test_data = {"z": "last", "a": "first", "m": "middle"}
79
+
80
+ github_backup.json_dump_if_changed(test_data, output_file)
81
+
82
+ with codecs.open(output_file, "r", encoding="utf-8") as f:
83
+ content = f.read()
84
+
85
+ # Check for consistent formatting:
86
+ # - sorted keys
87
+ # - 4-space indent
88
+ # - comma-colon-space separator
89
+ expected = json.dumps(
90
+ test_data,
91
+ ensure_ascii=False,
92
+ sort_keys=True,
93
+ indent=4,
94
+ separators=(",", ": "),
95
+ )
96
+ assert content == expected
97
+
98
+ def test_atomic_write_always_used(self):
99
+ """Should always use temp file and rename for atomic writes."""
100
+ with tempfile.TemporaryDirectory() as tmpdir:
101
+ output_file = os.path.join(tmpdir, "test.json")
102
+ test_data = {"key": "value"}
103
+
104
+ result = github_backup.json_dump_if_changed(test_data, output_file)
105
+
106
+ assert result is True
107
+ assert os.path.exists(output_file)
108
+
109
+ # Temp file should not exist after atomic write
110
+ temp_file = output_file + ".temp"
111
+ assert not os.path.exists(temp_file)
112
+
113
+ # Verify content
114
+ with codecs.open(output_file, "r", encoding="utf-8") as f:
115
+ loaded = json.load(f)
116
+ assert loaded == test_data
117
+
118
+ def test_handles_unicode_content(self):
119
+ """Should correctly handle Unicode content."""
120
+ with tempfile.TemporaryDirectory() as tmpdir:
121
+ output_file = os.path.join(tmpdir, "test.json")
122
+ test_data = {
123
+ "emoji": "🚀",
124
+ "chinese": "你好",
125
+ "arabic": "مرحبا",
126
+ "cyrillic": "Привет",
127
+ }
128
+
129
+ result = github_backup.json_dump_if_changed(test_data, output_file)
130
+ assert result is True
131
+
132
+ # Verify Unicode is preserved
133
+ with codecs.open(output_file, "r", encoding="utf-8") as f:
134
+ loaded = json.load(f)
135
+ assert loaded == test_data
136
+
137
+ # Second write should skip
138
+ result2 = github_backup.json_dump_if_changed(test_data, output_file)
139
+ assert result2 is False
140
+
141
+ def test_handles_complex_nested_data(self):
142
+ """Should handle complex nested data structures."""
143
+ with tempfile.TemporaryDirectory() as tmpdir:
144
+ output_file = os.path.join(tmpdir, "test.json")
145
+ test_data = {
146
+ "users": [
147
+ {"id": 1, "name": "Alice", "tags": ["admin", "user"]},
148
+ {"id": 2, "name": "Bob", "tags": ["user"]},
149
+ ],
150
+ "metadata": {"version": "1.0", "nested": {"deep": {"value": 42}}},
151
+ }
152
+
153
+ result = github_backup.json_dump_if_changed(test_data, output_file)
154
+ assert result is True
155
+
156
+ # Verify structure is preserved
157
+ with codecs.open(output_file, "r", encoding="utf-8") as f:
158
+ loaded = json.load(f)
159
+ assert loaded == test_data
160
+
161
+ def test_overwrites_on_unicode_decode_error(self):
162
+ """Should overwrite if existing file has invalid UTF-8."""
163
+ with tempfile.TemporaryDirectory() as tmpdir:
164
+ output_file = os.path.join(tmpdir, "test.json")
165
+ test_data = {"key": "value"}
166
+
167
+ # Write invalid UTF-8 bytes
168
+ with open(output_file, "wb") as f:
169
+ f.write(b"\xff\xfe invalid utf-8")
170
+
171
+ # Should catch UnicodeDecodeError and overwrite
172
+ result = github_backup.json_dump_if_changed(test_data, output_file)
173
+ assert result is True
174
+
175
+ # Verify new content was written
176
+ with codecs.open(output_file, "r", encoding="utf-8") as f:
177
+ loaded = json.load(f)
178
+ assert loaded == test_data
179
+
180
+ def test_key_order_independence(self):
181
+ """Should treat differently-ordered dicts as same if keys/values match."""
182
+ with tempfile.TemporaryDirectory() as tmpdir:
183
+ output_file = os.path.join(tmpdir, "test.json")
184
+
185
+ # Write first dict
186
+ data1 = {"z": 1, "a": 2, "m": 3}
187
+ github_backup.json_dump_if_changed(data1, output_file)
188
+
189
+ # Try to write same data but different order
190
+ data2 = {"a": 2, "m": 3, "z": 1}
191
+ result = github_backup.json_dump_if_changed(data2, output_file)
192
+
193
+ # Should skip because content is the same (keys are sorted)
194
+ assert result is False
195
+
196
+
197
+ if __name__ == "__main__":
198
+ pytest.main([__file__, "-v"])
@@ -1 +0,0 @@
1
- __version__ = "0.52.0"
File without changes
File without changes
File without changes