github-backup 0.52.0__tar.gz → 0.54.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.54.0}/CHANGES.rst +51 -1
  2. {github_backup-0.52.0/github_backup.egg-info → github_backup-0.54.0}/PKG-INFO +26 -2
  3. {github_backup-0.52.0 → github_backup-0.54.0}/README +25 -1
  4. {github_backup-0.52.0 → github_backup-0.54.0}/README.rst +25 -1
  5. {github_backup-0.52.0 → github_backup-0.54.0}/bin/github-backup +14 -3
  6. github_backup-0.54.0/github_backup/__init__.py +1 -0
  7. {github_backup-0.52.0 → github_backup-0.54.0}/github_backup/github_backup.py +88 -12
  8. {github_backup-0.52.0 → github_backup-0.54.0/github_backup.egg-info}/PKG-INFO +26 -2
  9. {github_backup-0.52.0 → github_backup-0.54.0}/github_backup.egg-info/SOURCES.txt +1 -0
  10. github_backup-0.54.0/tests/test_json_dump_if_changed.py +198 -0
  11. github_backup-0.52.0/github_backup/__init__.py +0 -1
  12. {github_backup-0.52.0 → github_backup-0.54.0}/LICENSE.txt +0 -0
  13. {github_backup-0.52.0 → github_backup-0.54.0}/MANIFEST.in +0 -0
  14. {github_backup-0.52.0 → github_backup-0.54.0}/github_backup.egg-info/dependency_links.txt +0 -0
  15. {github_backup-0.52.0 → github_backup-0.54.0}/github_backup.egg-info/top_level.txt +0 -0
  16. {github_backup-0.52.0 → github_backup-0.54.0}/github_backup.egg-info/zip-safe +0 -0
  17. {github_backup-0.52.0 → github_backup-0.54.0}/release-requirements.txt +0 -0
  18. {github_backup-0.52.0 → github_backup-0.54.0}/requirements.txt +0 -0
  19. {github_backup-0.52.0 → github_backup-0.54.0}/setup.cfg +0 -0
  20. {github_backup-0.52.0 → github_backup-0.54.0}/setup.py +0 -0
  21. {github_backup-0.52.0 → github_backup-0.54.0}/tests/test_attachments.py +0 -0
  22. {github_backup-0.52.0 → github_backup-0.54.0}/tests/test_http_451.py +0 -0
  23. {github_backup-0.52.0 → github_backup-0.54.0}/tests/test_pagination.py +0 -0
@@ -1,9 +1,59 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.52.0 (2025-11-28)
4
+ 0.54.0 (2025-12-03)
5
5
  -------------------
6
6
  ------------------------
7
+
8
+ Fix
9
+ ~~~
10
+ - Send INFO/DEBUG to stdout, WARNING/ERROR to stderr. [Rodos]
11
+
12
+ Fixes #182
13
+
14
+ Other
15
+ ~~~~~
16
+ - Docs: update README testing section and add fetch vs pull explanation.
17
+ [Rodos]
18
+
19
+
20
+ 0.53.0 (2025-11-30)
21
+ -------------------
22
+
23
+ Fix
24
+ ~~~
25
+ - Case-sensitive username filtering causing silent backup failures.
26
+ [Rodos]
27
+
28
+ GitHub's API accepts usernames in any case but returns canonical case.
29
+ The case-sensitive comparison in filter_repositories() filtered out all
30
+ repositories when user-provided case didn't match GitHub's canonical case.
31
+
32
+ Changed to case-insensitive comparison.
33
+
34
+ Fixes #198
35
+
36
+ Other
37
+ ~~~~~
38
+ - Avoid rewriting unchanged JSON files for labels, milestones, releases,
39
+ hooks, followers, and following. [Rodos]
40
+
41
+ This change reduces unnecessary writes when backing up metadata that changes
42
+ infrequently. The implementation compares existing file content before writing
43
+ and skips the write if the content is identical, preserving file timestamps.
44
+
45
+ Key changes:
46
+ - Added json_dump_if_changed() helper that compares content before writing
47
+ - Uses atomic writes (temp file + rename) for all metadata files
48
+ - NOT applied to issues/pulls (they use incremental_by_files logic)
49
+ - Made log messages consistent and past tense ("Saved" instead of "Saving")
50
+ - Added informative logging showing skip counts
51
+
52
+ Fixes #133
53
+
54
+
55
+ 0.52.0 (2025-11-28)
56
+ -------------------
7
57
  - Skip DMCA'd repos which return a 451 response. [Rodos]
8
58
 
9
59
  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.54.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
@@ -338,6 +338,25 @@ Skip existing on incomplete backups
338
338
  The ``--skip-existing`` argument will skip a backup if the directory already exists, even if the backup in that directory failed (perhaps due to a blocking error). This may result in unexpected missing data in a regular backup.
339
339
 
340
340
 
341
+ Updates use fetch, not pull
342
+ ---------------------------
343
+
344
+ When updating an existing repository backup, ``github-backup`` uses ``git fetch`` rather than ``git pull``. This is intentional - a backup tool should reliably download data without risk of failure. Using ``git pull`` would require handling merge conflicts, which adds complexity and could cause backups to fail unexpectedly.
345
+
346
+ With fetch, **all branches and commits are downloaded** safely into remote-tracking branches. The working directory files won't change, but your backup is complete.
347
+
348
+ If you look at files directly (e.g., ``cat README.md``), you'll see the old content. The new data is in the remote-tracking branches (confusingly named "remote" but stored locally). To view or use the latest files::
349
+
350
+ git show origin/main:README.md # view a file
351
+ git merge origin/main # update working directory
352
+
353
+ All branches are backed up as remote refs (``origin/main``, ``origin/feature-branch``, etc.).
354
+
355
+ If you want to browse files directly without merging, consider using ``--bare`` which skips the working directory entirely - the backup is just the git data.
356
+
357
+ See `#269 <https://github.com/josegonzalez/python-github-backup/issues/269>`_ for more discussion.
358
+
359
+
341
360
  Github Backup Examples
342
361
  ======================
343
362
 
@@ -387,7 +406,12 @@ A huge thanks to all the contibuters!
387
406
  Testing
388
407
  -------
389
408
 
390
- This project currently contains no unit tests. To run linting::
409
+ To run the test suite::
410
+
411
+ pip install pytest
412
+ pytest
413
+
414
+ To run linting::
391
415
 
392
416
  pip install flake8
393
417
  flake8 --ignore=E501
@@ -308,6 +308,25 @@ Skip existing on incomplete backups
308
308
  The ``--skip-existing`` argument will skip a backup if the directory already exists, even if the backup in that directory failed (perhaps due to a blocking error). This may result in unexpected missing data in a regular backup.
309
309
 
310
310
 
311
+ Updates use fetch, not pull
312
+ ---------------------------
313
+
314
+ When updating an existing repository backup, ``github-backup`` uses ``git fetch`` rather than ``git pull``. This is intentional - a backup tool should reliably download data without risk of failure. Using ``git pull`` would require handling merge conflicts, which adds complexity and could cause backups to fail unexpectedly.
315
+
316
+ With fetch, **all branches and commits are downloaded** safely into remote-tracking branches. The working directory files won't change, but your backup is complete.
317
+
318
+ If you look at files directly (e.g., ``cat README.md``), you'll see the old content. The new data is in the remote-tracking branches (confusingly named "remote" but stored locally). To view or use the latest files::
319
+
320
+ git show origin/main:README.md # view a file
321
+ git merge origin/main # update working directory
322
+
323
+ All branches are backed up as remote refs (``origin/main``, ``origin/feature-branch``, etc.).
324
+
325
+ If you want to browse files directly without merging, consider using ``--bare`` which skips the working directory entirely - the backup is just the git data.
326
+
327
+ See `#269 <https://github.com/josegonzalez/python-github-backup/issues/269>`_ for more discussion.
328
+
329
+
311
330
  Github Backup Examples
312
331
  ======================
313
332
 
@@ -357,7 +376,12 @@ A huge thanks to all the contibuters!
357
376
  Testing
358
377
  -------
359
378
 
360
- This project currently contains no unit tests. To run linting::
379
+ To run the test suite::
380
+
381
+ pip install pytest
382
+ pytest
383
+
384
+ To run linting::
361
385
 
362
386
  pip install flake8
363
387
  flake8 --ignore=E501
@@ -308,6 +308,25 @@ Skip existing on incomplete backups
308
308
  The ``--skip-existing`` argument will skip a backup if the directory already exists, even if the backup in that directory failed (perhaps due to a blocking error). This may result in unexpected missing data in a regular backup.
309
309
 
310
310
 
311
+ Updates use fetch, not pull
312
+ ---------------------------
313
+
314
+ When updating an existing repository backup, ``github-backup`` uses ``git fetch`` rather than ``git pull``. This is intentional - a backup tool should reliably download data without risk of failure. Using ``git pull`` would require handling merge conflicts, which adds complexity and could cause backups to fail unexpectedly.
315
+
316
+ With fetch, **all branches and commits are downloaded** safely into remote-tracking branches. The working directory files won't change, but your backup is complete.
317
+
318
+ If you look at files directly (e.g., ``cat README.md``), you'll see the old content. The new data is in the remote-tracking branches (confusingly named "remote" but stored locally). To view or use the latest files::
319
+
320
+ git show origin/main:README.md # view a file
321
+ git merge origin/main # update working directory
322
+
323
+ All branches are backed up as remote refs (``origin/main``, ``origin/feature-branch``, etc.).
324
+
325
+ If you want to browse files directly without merging, consider using ``--bare`` which skips the working directory entirely - the backup is just the git data.
326
+
327
+ See `#269 <https://github.com/josegonzalez/python-github-backup/issues/269>`_ for more discussion.
328
+
329
+
311
330
  Github Backup Examples
312
331
  ======================
313
332
 
@@ -357,7 +376,12 @@ A huge thanks to all the contibuters!
357
376
  Testing
358
377
  -------
359
378
 
360
- This project currently contains no unit tests. To run linting::
379
+ To run the test suite::
380
+
381
+ pip install pytest
382
+ pytest
383
+
384
+ To run linting::
361
385
 
362
386
  pip install flake8
363
387
  flake8 --ignore=E501
@@ -16,12 +16,23 @@ from github_backup.github_backup import (
16
16
  retrieve_repositories,
17
17
  )
18
18
 
19
- logging.basicConfig(
20
- format="%(asctime)s.%(msecs)03d: %(message)s",
19
+ # INFO and DEBUG go to stdout, WARNING and above go to stderr
20
+ log_format = logging.Formatter(
21
+ fmt="%(asctime)s.%(msecs)03d: %(message)s",
21
22
  datefmt="%Y-%m-%dT%H:%M:%S",
22
- level=logging.INFO,
23
23
  )
24
24
 
25
+ stdout_handler = logging.StreamHandler(sys.stdout)
26
+ stdout_handler.setLevel(logging.DEBUG)
27
+ stdout_handler.addFilter(lambda r: r.levelno < logging.WARNING)
28
+ stdout_handler.setFormatter(log_format)
29
+
30
+ stderr_handler = logging.StreamHandler(sys.stderr)
31
+ stderr_handler.setLevel(logging.WARNING)
32
+ stderr_handler.setFormatter(log_format)
33
+
34
+ logging.basicConfig(level=logging.INFO, handlers=[stdout_handler, stderr_handler])
35
+
25
36
 
26
37
  def main():
27
38
  args = parse_args()
@@ -0,0 +1 @@
1
+ __version__ = "0.54.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.54.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
@@ -338,6 +338,25 @@ Skip existing on incomplete backups
338
338
  The ``--skip-existing`` argument will skip a backup if the directory already exists, even if the backup in that directory failed (perhaps due to a blocking error). This may result in unexpected missing data in a regular backup.
339
339
 
340
340
 
341
+ Updates use fetch, not pull
342
+ ---------------------------
343
+
344
+ When updating an existing repository backup, ``github-backup`` uses ``git fetch`` rather than ``git pull``. This is intentional - a backup tool should reliably download data without risk of failure. Using ``git pull`` would require handling merge conflicts, which adds complexity and could cause backups to fail unexpectedly.
345
+
346
+ With fetch, **all branches and commits are downloaded** safely into remote-tracking branches. The working directory files won't change, but your backup is complete.
347
+
348
+ If you look at files directly (e.g., ``cat README.md``), you'll see the old content. The new data is in the remote-tracking branches (confusingly named "remote" but stored locally). To view or use the latest files::
349
+
350
+ git show origin/main:README.md # view a file
351
+ git merge origin/main # update working directory
352
+
353
+ All branches are backed up as remote refs (``origin/main``, ``origin/feature-branch``, etc.).
354
+
355
+ If you want to browse files directly without merging, consider using ``--bare`` which skips the working directory entirely - the backup is just the git data.
356
+
357
+ See `#269 <https://github.com/josegonzalez/python-github-backup/issues/269>`_ for more discussion.
358
+
359
+
341
360
  Github Backup Examples
342
361
  ======================
343
362
 
@@ -387,7 +406,12 @@ A huge thanks to all the contibuters!
387
406
  Testing
388
407
  -------
389
408
 
390
- This project currently contains no unit tests. To run linting::
409
+ To run the test suite::
410
+
411
+ pip install pytest
412
+ pytest
413
+
414
+ To run linting::
391
415
 
392
416
  pip install flake8
393
417
  flake8 --ignore=E501
@@ -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