github-backup 0.52.0__py3-none-any.whl → 0.54.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.52.0"
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
@@ -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()
@@ -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
@@ -0,0 +1,9 @@
1
+ github_backup/__init__.py,sha256=GL361gEztfNayz_pZjsGe3Es4PhpuAOve1lCqPCbSZs,23
2
+ github_backup/github_backup.py,sha256=-7oiAGDfavMxSiqWgaiyS5vYq31kFxOXgEH0Vw5MTIo,80149
3
+ github_backup-0.54.0.data/scripts/github-backup,sha256=hC59RgEKx2RbHKC-eQkKfB3JXfiNIvIO4VAXiodSkLw,2004
4
+ github_backup-0.54.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
5
+ github_backup-0.54.0.dist-info/METADATA,sha256=CJyhg2JeoygmRFtyGbyjs0PLOoy04IkdC4mLnu1WOnM,22810
6
+ github_backup-0.54.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ github_backup-0.54.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
8
+ github_backup-0.54.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
9
+ github_backup-0.54.0.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- github_backup/__init__.py,sha256=uXy9jtvBCb6cwZyNng8d7tehPtkx2Xcdr7Lm3SEzEF8,23
2
- github_backup/github_backup.py,sha256=eo8P69vIgeP_ntzY4DdGrzFnEKPIoqBrgBX3Dw_2lIo,77487
3
- github_backup-0.52.0.data/scripts/github-backup,sha256=I79TNA67phytRmI9nm_LlD9h2zauXFOsOn9tYyAy9pU,1546
4
- github_backup-0.52.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
5
- github_backup-0.52.0.dist-info/METADATA,sha256=92rNj52N87cGNzBGRJYCyaotJIEgZ5ZgZ-DUugpuvlI,21544
6
- github_backup-0.52.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
- github_backup-0.52.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
8
- github_backup-0.52.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
9
- github_backup-0.52.0.dist-info/RECORD,,