github-backup 0.56.0__py3-none-any.whl → 0.58.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.56.0"
1
+ __version__ = "0.58.0"
@@ -12,6 +12,7 @@ import json
12
12
  import logging
13
13
  import os
14
14
  import platform
15
+ import random
15
16
  import re
16
17
  import select
17
18
  import socket
@@ -19,6 +20,7 @@ import ssl
19
20
  import subprocess
20
21
  import sys
21
22
  import time
23
+ from collections.abc import Generator
22
24
  from datetime import datetime
23
25
  from http.client import IncompleteRead
24
26
  from urllib.error import HTTPError, URLError
@@ -74,6 +76,9 @@ else:
74
76
  " 3. Debian/Ubuntu: apt-get install ca-certificates\n\n"
75
77
  )
76
78
 
79
+ # Retry configuration
80
+ MAX_RETRIES = 5
81
+
77
82
 
78
83
  def logging_subprocess(
79
84
  popenargs, stdout_log_level=logging.DEBUG, stderr_log_level=logging.ERROR, **kwargs
@@ -440,6 +445,12 @@ def parse_args(args=None):
440
445
  dest="include_assets",
441
446
  help="include assets alongside release information; only applies if including releases",
442
447
  )
448
+ parser.add_argument(
449
+ "--skip-assets-on",
450
+ dest="skip_assets_on",
451
+ nargs="*",
452
+ help="skip asset downloads for these repositories",
453
+ )
443
454
  parser.add_argument(
444
455
  "--attachments",
445
456
  action="store_true",
@@ -561,7 +572,7 @@ def get_github_host(args):
561
572
 
562
573
 
563
574
  def read_file_contents(file_uri):
564
- return open(file_uri[len(FILE_URI_PREFIX):], "rt").readline().strip()
575
+ return open(file_uri[len(FILE_URI_PREFIX) :], "rt").readline().strip()
565
576
 
566
577
 
567
578
  def get_github_repo_url(args, repository):
@@ -597,175 +608,181 @@ def get_github_repo_url(args, repository):
597
608
  return repo_url
598
609
 
599
610
 
600
- def retrieve_data_gen(args, template, query_args=None, single_request=False):
601
- auth = get_auth(args, encode=not args.as_app)
602
- query_args = get_query_args(query_args)
603
- per_page = 100
604
- next_url = None
611
+ def calculate_retry_delay(attempt, headers):
612
+ """Calculate delay before next retry with exponential backoff."""
613
+ # Respect retry-after header if present
614
+ if retry_after := headers.get("retry-after"):
615
+ return int(retry_after)
605
616
 
606
- while True:
607
- if single_request:
608
- request_per_page = None
609
- else:
610
- request_per_page = per_page
617
+ # Respect rate limit reset time
618
+ if int(headers.get("x-ratelimit-remaining", 1)) < 1:
619
+ reset_time = int(headers.get("x-ratelimit-reset", 0))
620
+ return max(10, reset_time - calendar.timegm(time.gmtime()))
611
621
 
612
- request = _construct_request(
613
- request_per_page,
614
- query_args,
615
- next_url or template,
616
- auth,
617
- as_app=args.as_app,
618
- fine=True if args.token_fine is not None else False,
619
- ) # noqa
620
- r, errors = _get_response(request, auth, next_url or template)
622
+ # Exponential backoff with jitter for server errors (1s base, 120s max)
623
+ delay = min(1.0 * (2**attempt), 120.0)
624
+ return delay + random.uniform(0, delay * 0.1)
621
625
 
622
- status_code = int(r.getcode())
623
626
 
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
- )
627
+ def retrieve_data(args, template, query_args=None, paginated=True):
628
+ """
629
+ Fetch the data from GitHub API.
636
630
 
637
- # Check if we got correct data
638
- try:
639
- response = json.loads(r.read().decode("utf-8"))
640
- except IncompleteRead:
641
- logger.warning("Incomplete read error detected")
642
- read_error = True
643
- except json.decoder.JSONDecodeError:
644
- logger.warning("JSON decode error detected")
645
- read_error = True
646
- except TimeoutError:
647
- logger.warning("Tiemout error detected")
648
- read_error = True
649
- else:
650
- read_error = False
631
+ Handle both single requests and pagination with yield of individual dicts.
632
+ Handles throttling, retries, read errors, and DMCA takedowns.
633
+ """
634
+ query_args = query_args or {}
635
+ auth = get_auth(args, encode=not args.as_app)
636
+ per_page = 100
651
637
 
652
- # be gentle with API request limit and throttle requests if remaining requests getting low
653
- limit_remaining = int(r.headers.get("x-ratelimit-remaining", 0))
654
- if args.throttle_limit and limit_remaining <= args.throttle_limit:
655
- logger.info(
656
- "API request limit hit: {} requests left, pausing further requests for {}s".format(
657
- limit_remaining, args.throttle_pause
638
+ def _extract_next_page_url(link_header):
639
+ for link in link_header.split(","):
640
+ if 'rel="next"' in link:
641
+ return link[link.find("<") + 1:link.find(">")]
642
+ return None
643
+
644
+ def fetch_all() -> Generator[dict, None, None]:
645
+ next_url = None
646
+
647
+ while True:
648
+ # FIRST: Fetch response
649
+
650
+ for attempt in range(MAX_RETRIES):
651
+ request = _construct_request(
652
+ per_page=per_page if paginated else None,
653
+ query_args=query_args,
654
+ template=next_url or template,
655
+ auth=auth,
656
+ as_app=args.as_app,
657
+ fine=args.token_fine is not None,
658
+ )
659
+ http_response = make_request_with_retry(request, auth)
660
+
661
+ match http_response.getcode():
662
+ case 200:
663
+ # Success - Parse JSON response
664
+ try:
665
+ response = json.loads(http_response.read().decode("utf-8"))
666
+ break # Exit retry loop and handle the data returned
667
+ except (
668
+ IncompleteRead,
669
+ json.decoder.JSONDecodeError,
670
+ TimeoutError,
671
+ ) as e:
672
+ logger.warning(f"{type(e).__name__} reading response")
673
+ if attempt < MAX_RETRIES - 1:
674
+ delay = calculate_retry_delay(attempt, {})
675
+ logger.warning(
676
+ f"Retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})"
677
+ )
678
+ time.sleep(delay)
679
+ continue # Next retry attempt
680
+
681
+ case 451:
682
+ # DMCA takedown - extract URL if available, then raise
683
+ dmca_url = None
684
+ try:
685
+ response_data = json.loads(
686
+ http_response.read().decode("utf-8")
687
+ )
688
+ dmca_url = response_data.get("block", {}).get("html_url")
689
+ except Exception:
690
+ pass
691
+ raise RepositoryUnavailableError(
692
+ "Repository unavailable due to legal reasons (HTTP 451)",
693
+ dmca_url=dmca_url,
694
+ )
695
+
696
+ case _:
697
+ raise Exception(
698
+ f"API request returned HTTP {http_response.getcode()}: {http_response.reason}"
699
+ )
700
+ else:
701
+ logger.error(
702
+ f"Failed to read response after {MAX_RETRIES} attempts for {next_url or template}"
703
+ )
704
+ raise Exception(
705
+ f"Failed to read response after {MAX_RETRIES} attempts for {next_url or template}"
658
706
  )
659
- )
660
- time.sleep(args.throttle_pause)
661
-
662
- retries = 0
663
- while retries < 3 and (status_code == 502 or read_error):
664
- logger.warning("API request failed. Retrying in 5 seconds")
665
- retries += 1
666
- time.sleep(5)
667
- request = _construct_request(
668
- request_per_page,
669
- query_args,
670
- next_url or template,
671
- auth,
672
- as_app=args.as_app,
673
- fine=True if args.token_fine is not None else False,
674
- ) # noqa
675
- r, errors = _get_response(request, auth, next_url or template)
676
-
677
- status_code = int(r.getcode())
678
- try:
679
- response = json.loads(r.read().decode("utf-8"))
680
- read_error = False
681
- except IncompleteRead:
682
- logger.warning("Incomplete read error detected")
683
- read_error = True
684
- except json.decoder.JSONDecodeError:
685
- logger.warning("JSON decode error detected")
686
- read_error = True
687
- except TimeoutError:
688
- logger.warning("Tiemout error detected")
689
- read_error = True
690
-
691
- if status_code != 200:
692
- template = "API request returned HTTP {0}: {1}"
693
- errors.append(template.format(status_code, r.reason))
694
- raise Exception(", ".join(errors))
695
-
696
- if read_error:
697
- template = "API request problem reading response for {0}"
698
- errors.append(template.format(request))
699
- raise Exception(", ".join(errors))
700
-
701
- if len(errors) == 0:
702
- if type(response) is list:
703
- for resp in response:
704
- yield resp
705
- # Parse Link header for next page URL (cursor-based pagination)
706
- link_header = r.headers.get("Link", "")
707
- next_url = None
708
- if link_header:
709
- # Parse Link header: <https://api.github.com/...?per_page=100&after=cursor>; rel="next"
710
- for link in link_header.split(","):
711
- if 'rel="next"' in link:
712
- next_url = link[link.find("<") + 1:link.find(">")]
713
- break
714
- if not next_url:
715
- break
716
- elif type(response) is dict and single_request:
717
- yield response
718
707
 
719
- if len(errors) > 0:
720
- raise Exception(", ".join(errors))
708
+ # SECOND: Process and paginate
721
709
 
722
- if single_request:
723
- break
710
+ # Pause before next request if rate limit is low
711
+ if (
712
+ remaining := int(http_response.headers.get("x-ratelimit-remaining", 0))
713
+ ) <= (args.throttle_limit or 0):
714
+ if args.throttle_limit:
715
+ logger.info(
716
+ f"Throttling: {remaining} requests left, pausing {args.throttle_pause}s"
717
+ )
718
+ time.sleep(args.throttle_pause)
724
719
 
720
+ # Yield results
721
+ if isinstance(response, list):
722
+ yield from response
723
+ elif isinstance(response, dict):
724
+ yield response
725
725
 
726
- def retrieve_data(args, template, query_args=None, single_request=False):
727
- return list(retrieve_data_gen(args, template, query_args, single_request))
726
+ # Check for more pages
727
+ if not paginated or not (
728
+ next_url := _extract_next_page_url(
729
+ http_response.headers.get("Link", "")
730
+ )
731
+ ):
732
+ break # No more data
728
733
 
734
+ return list(fetch_all())
729
735
 
730
- def get_query_args(query_args=None):
731
- if not query_args:
732
- query_args = {}
733
- return query_args
734
736
 
737
+ def make_request_with_retry(request, auth):
738
+ """Make HTTP request with automatic retry for transient errors."""
735
739
 
736
- def _get_response(request, auth, template):
737
- retry_timeout = 3
738
- errors = []
739
- # We'll make requests in a loop so we can
740
- # delay and retry in the case of rate-limiting
741
- while True:
742
- should_continue = False
740
+ def is_retryable_status(status_code, headers):
741
+ # Server errors are always retryable
742
+ if status_code in (500, 502, 503, 504):
743
+ return True
744
+ # Rate limit (403/429) is retryable if limit exhausted
745
+ if status_code in (403, 429):
746
+ return int(headers.get("x-ratelimit-remaining", 1)) < 1
747
+ return False
748
+
749
+ for attempt in range(MAX_RETRIES):
743
750
  try:
744
- r = urlopen(request, context=https_ctx)
751
+ return urlopen(request, context=https_ctx)
752
+
745
753
  except HTTPError as exc:
746
- errors, should_continue = _request_http_error(exc, auth, errors) # noqa
747
- r = exc
748
- except URLError as e:
749
- logger.warning(e.reason)
750
- should_continue, retry_timeout = _request_url_error(template, retry_timeout)
751
- if not should_continue:
752
- raise
753
- except socket.error as e:
754
- logger.warning(e.strerror)
755
- should_continue, retry_timeout = _request_url_error(template, retry_timeout)
756
- if not should_continue:
754
+ # HTTPError can be used as a response-like object
755
+ if not is_retryable_status(exc.code, exc.headers):
756
+ raise # Non-retryable error
757
+
758
+ if attempt >= MAX_RETRIES - 1:
759
+ logger.error(f"HTTP {exc.code} failed after {MAX_RETRIES} attempts")
757
760
  raise
758
761
 
759
- if should_continue:
760
- continue
762
+ delay = calculate_retry_delay(attempt, exc.headers)
763
+ logger.warning(
764
+ f"HTTP {exc.code}, retrying in {delay:.1f}s "
765
+ f"(attempt {attempt + 1}/{MAX_RETRIES})"
766
+ )
767
+ if auth is None and exc.code in (403, 429):
768
+ logger.info("Hint: Authenticate to raise your GitHub rate limit")
769
+ time.sleep(delay)
761
770
 
762
- break
763
- return r, errors
771
+ except (URLError, socket.error) as e:
772
+ if attempt >= MAX_RETRIES - 1:
773
+ logger.error(f"Connection error failed after {MAX_RETRIES} attempts: {e}")
774
+ raise
775
+ delay = calculate_retry_delay(attempt, {})
776
+ logger.warning(
777
+ f"Connection error: {e}, retrying in {delay:.1f}s "
778
+ f"(attempt {attempt + 1}/{MAX_RETRIES})"
779
+ )
780
+ time.sleep(delay)
764
781
 
782
+ raise Exception(f"Request failed after {MAX_RETRIES} attempts") # pragma: no cover
765
783
 
766
- def _construct_request(
767
- per_page, query_args, template, auth, as_app=None, fine=False
768
- ):
784
+
785
+ def _construct_request(per_page, query_args, template, auth, as_app=None, fine=False):
769
786
  # If template is already a full URL with query params (from Link header), use it directly
770
787
  if "?" in template and template.startswith("http"):
771
788
  request_url = template
@@ -796,9 +813,6 @@ def _construct_request(
796
813
  else:
797
814
  auth = auth.encode("ascii")
798
815
  request.add_header("Authorization", "token ".encode("ascii") + auth)
799
- request.add_header(
800
- "Accept", "application/vnd.github.machine-man-preview+json"
801
- )
802
816
 
803
817
  log_url = template if "?" not in template else template.split("?")[0]
804
818
  if querystring:
@@ -807,52 +821,6 @@ def _construct_request(
807
821
  return request
808
822
 
809
823
 
810
- def _request_http_error(exc, auth, errors):
811
- # HTTPError behaves like a Response so we can
812
- # check the status code and headers to see exactly
813
- # what failed.
814
-
815
- should_continue = False
816
- headers = exc.headers
817
- limit_remaining = int(headers.get("x-ratelimit-remaining", 0))
818
-
819
- if exc.code == 403 and limit_remaining < 1:
820
- # The X-RateLimit-Reset header includes a
821
- # timestamp telling us when the limit will reset
822
- # so we can calculate how long to wait rather
823
- # than inefficiently polling:
824
- gm_now = calendar.timegm(time.gmtime())
825
- reset = int(headers.get("x-ratelimit-reset", 0)) or gm_now
826
- # We'll never sleep for less than 10 seconds:
827
- delta = max(10, reset - gm_now)
828
-
829
- limit = headers.get("x-ratelimit-limit")
830
- logger.warning(
831
- "Exceeded rate limit of {} requests; waiting {} seconds to reset".format(
832
- limit, delta
833
- )
834
- ) # noqa
835
-
836
- if auth is None:
837
- logger.info("Hint: Authenticate to raise your GitHub rate limit")
838
-
839
- time.sleep(delta)
840
- should_continue = True
841
- return errors, should_continue
842
-
843
-
844
- def _request_url_error(template, retry_timeout):
845
- # In case of a connection timing out, we can retry a few time
846
- # But we won't crash and not back-up the rest now
847
- logger.info("'{}' timed out".format(template))
848
- retry_timeout -= 1
849
-
850
- if retry_timeout >= 0:
851
- return True, retry_timeout
852
-
853
- raise Exception("'{}' timed out to much, skipping!".format(template))
854
-
855
-
856
824
  class S3HTTPRedirectHandler(HTTPRedirectHandler):
857
825
  """
858
826
  A subclassed redirect handler for downloading Github assets from S3.
@@ -1480,9 +1448,11 @@ def download_attachments(
1480
1448
  manifest = {
1481
1449
  "issue_number": number,
1482
1450
  "issue_type": item_type,
1483
- "repository": f"{args.user}/{args.repository}"
1484
- if hasattr(args, "repository") and args.repository
1485
- else args.user,
1451
+ "repository": (
1452
+ f"{args.user}/{args.repository}"
1453
+ if hasattr(args, "repository") and args.repository
1454
+ else args.user
1455
+ ),
1486
1456
  "manifest_updated_at": datetime.now(timezone.utc).isoformat(),
1487
1457
  "attachments": attachment_metadata_list,
1488
1458
  }
@@ -1500,7 +1470,7 @@ def download_attachments(
1500
1470
 
1501
1471
  def get_authenticated_user(args):
1502
1472
  template = "https://{0}/user".format(get_github_api_host(args))
1503
- data = retrieve_data(args, template, single_request=True)
1473
+ data = retrieve_data(args, template, paginated=False)
1504
1474
  return data[0]
1505
1475
 
1506
1476
 
@@ -1514,7 +1484,7 @@ def check_git_lfs_install():
1514
1484
 
1515
1485
  def retrieve_repositories(args, authenticated_user):
1516
1486
  logger.info("Retrieving repositories")
1517
- single_request = False
1487
+ paginated = True
1518
1488
  if args.user == authenticated_user["login"]:
1519
1489
  # we must use the /user/repos API to be able to access private repos
1520
1490
  template = "https://{0}/user/repos".format(get_github_api_host(args))
@@ -1537,18 +1507,16 @@ def retrieve_repositories(args, authenticated_user):
1537
1507
  repo_path = args.repository
1538
1508
  else:
1539
1509
  repo_path = "{0}/{1}".format(args.user, args.repository)
1540
- single_request = True
1541
- template = "https://{0}/repos/{1}".format(
1542
- get_github_api_host(args), repo_path
1543
- )
1510
+ paginated = False
1511
+ template = "https://{0}/repos/{1}".format(get_github_api_host(args), repo_path)
1544
1512
 
1545
- repos = retrieve_data(args, template, single_request=single_request)
1513
+ repos = retrieve_data(args, template, paginated=paginated)
1546
1514
 
1547
1515
  if args.all_starred:
1548
1516
  starred_template = "https://{0}/users/{1}/starred".format(
1549
1517
  get_github_api_host(args), args.user
1550
1518
  )
1551
- starred_repos = retrieve_data(args, starred_template, single_request=False)
1519
+ starred_repos = retrieve_data(args, starred_template)
1552
1520
  # flag each repo as starred for downstream processing
1553
1521
  for item in starred_repos:
1554
1522
  item.update({"is_starred": True})
@@ -1558,14 +1526,17 @@ def retrieve_repositories(args, authenticated_user):
1558
1526
  gists_template = "https://{0}/users/{1}/gists".format(
1559
1527
  get_github_api_host(args), args.user
1560
1528
  )
1561
- gists = retrieve_data(args, gists_template, single_request=False)
1529
+ gists = retrieve_data(args, gists_template)
1562
1530
  # flag each repo as a gist for downstream processing
1563
1531
  for item in gists:
1564
1532
  item.update({"is_gist": True})
1565
1533
  repos.extend(gists)
1566
1534
 
1567
1535
  if args.include_starred_gists:
1568
- if not authenticated_user.get("login") or args.user.lower() != authenticated_user["login"].lower():
1536
+ if (
1537
+ not authenticated_user.get("login")
1538
+ or args.user.lower() != authenticated_user["login"].lower()
1539
+ ):
1569
1540
  logger.warning(
1570
1541
  "Cannot retrieve starred gists for '%s'. GitHub only allows access to the authenticated user's starred gists.",
1571
1542
  args.user,
@@ -1574,9 +1545,7 @@ def retrieve_repositories(args, authenticated_user):
1574
1545
  starred_gists_template = "https://{0}/gists/starred".format(
1575
1546
  get_github_api_host(args)
1576
1547
  )
1577
- starred_gists = retrieve_data(
1578
- args, starred_gists_template, single_request=False
1579
- )
1548
+ starred_gists = retrieve_data(args, starred_gists_template)
1580
1549
  # flag each repo as a starred gist for downstream processing
1581
1550
  for item in starred_gists:
1582
1551
  item.update({"is_gist": True, "is_starred": True})
@@ -1673,9 +1642,11 @@ def backup_repositories(args, output_directory, repositories):
1673
1642
 
1674
1643
  include_gists = args.include_gists or args.include_starred_gists
1675
1644
  include_starred = args.all_starred and repository.get("is_starred")
1676
- if (args.include_repository or args.include_everything) or (
1677
- include_gists and repository.get("is_gist")
1678
- ) or include_starred:
1645
+ if (
1646
+ (args.include_repository or args.include_everything)
1647
+ or (include_gists and repository.get("is_gist"))
1648
+ or include_starred
1649
+ ):
1679
1650
  repo_name = (
1680
1651
  repository.get("name")
1681
1652
  if not repository.get("is_gist")
@@ -1735,7 +1706,9 @@ def backup_repositories(args, output_directory, repositories):
1735
1706
  include_assets=args.include_assets or args.include_everything,
1736
1707
  )
1737
1708
  except RepositoryUnavailableError as e:
1738
- logger.warning(f"Repository {repository['full_name']} is unavailable (HTTP 451)")
1709
+ logger.warning(
1710
+ f"Repository {repository['full_name']} is unavailable (HTTP 451)"
1711
+ )
1739
1712
  if e.dmca_url:
1740
1713
  logger.warning(f"DMCA notice: {e.dmca_url}")
1741
1714
  logger.info(f"Skipping remaining resources for {repository['full_name']}")
@@ -1795,7 +1768,11 @@ def backup_issues(args, repo_cwd, repository, repos_template):
1795
1768
  modified = os.path.getmtime(issue_file)
1796
1769
  modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%dT%H:%M:%SZ")
1797
1770
  if modified > issue["updated_at"]:
1798
- logger.info("Skipping issue {0} because it wasn't modified since last backup".format(number))
1771
+ logger.info(
1772
+ "Skipping issue {0} because it wasn't modified since last backup".format(
1773
+ number
1774
+ )
1775
+ )
1799
1776
  continue
1800
1777
 
1801
1778
  if args.include_issue_comments or args.include_everything:
@@ -1837,14 +1814,14 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1837
1814
  pull_states = ["open", "closed"]
1838
1815
  for pull_state in pull_states:
1839
1816
  query_args["state"] = pull_state
1840
- _pulls = retrieve_data_gen(args, _pulls_template, query_args=query_args)
1817
+ _pulls = retrieve_data(args, _pulls_template, query_args=query_args)
1841
1818
  for pull in _pulls:
1842
1819
  if args.since and pull["updated_at"] < args.since:
1843
1820
  break
1844
1821
  if not args.since or pull["updated_at"] >= args.since:
1845
1822
  pulls[pull["number"]] = pull
1846
1823
  else:
1847
- _pulls = retrieve_data_gen(args, _pulls_template, query_args=query_args)
1824
+ _pulls = retrieve_data(args, _pulls_template, query_args=query_args)
1848
1825
  for pull in _pulls:
1849
1826
  if args.since and pull["updated_at"] < args.since:
1850
1827
  break
@@ -1852,7 +1829,7 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1852
1829
  pulls[pull["number"]] = retrieve_data(
1853
1830
  args,
1854
1831
  _pulls_template + "/{}".format(pull["number"]),
1855
- single_request=True,
1832
+ paginated=False,
1856
1833
  )[0]
1857
1834
 
1858
1835
  logger.info("Saving {0} pull requests to disk".format(len(list(pulls.keys()))))
@@ -1869,7 +1846,11 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1869
1846
  modified = os.path.getmtime(pull_file)
1870
1847
  modified = datetime.fromtimestamp(modified).strftime("%Y-%m-%dT%H:%M:%SZ")
1871
1848
  if modified > pull["updated_at"]:
1872
- logger.info("Skipping pull request {0} because it wasn't modified since last backup".format(number))
1849
+ logger.info(
1850
+ "Skipping pull request {0} because it wasn't modified since last backup".format(
1851
+ number
1852
+ )
1853
+ )
1873
1854
  continue
1874
1855
  if args.include_pull_comments or args.include_everything:
1875
1856
  template = comments_regular_template.format(number)
@@ -1919,9 +1900,11 @@ def backup_milestones(args, repo_cwd, repository, repos_template):
1919
1900
  elif written_count == 0:
1920
1901
  logger.info("{0} milestones unchanged, skipped write".format(total))
1921
1902
  else:
1922
- logger.info("Saved {0} of {1} milestones to disk ({2} unchanged)".format(
1923
- written_count, total, total - written_count
1924
- ))
1903
+ logger.info(
1904
+ "Saved {0} of {1} milestones to disk ({2} unchanged)".format(
1905
+ written_count, total, total - written_count
1906
+ )
1907
+ )
1925
1908
 
1926
1909
 
1927
1910
  def backup_labels(args, repo_cwd, repository, repos_template):
@@ -1975,6 +1958,20 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
1975
1958
  )
1976
1959
  releases = releases[: args.number_of_latest_releases]
1977
1960
 
1961
+ # Check if this repo should skip asset downloads (case-insensitive)
1962
+ skip_assets = False
1963
+ if include_assets:
1964
+ repo_name = repository.get("name", "").lower()
1965
+ repo_full_name = repository.get("full_name", "").lower()
1966
+ skip_repos = [r.lower() for r in (args.skip_assets_on or [])]
1967
+ skip_assets = repo_name in skip_repos or repo_full_name in skip_repos
1968
+ if skip_assets:
1969
+ logger.info(
1970
+ "Skipping assets for {0} ({1} releases) due to --skip-assets-on".format(
1971
+ repository.get("name"), len(releases)
1972
+ )
1973
+ )
1974
+
1978
1975
  # for each release, store it
1979
1976
  written_count = 0
1980
1977
  for release in releases:
@@ -1986,7 +1983,7 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
1986
1983
  if json_dump_if_changed(release, output_filepath):
1987
1984
  written_count += 1
1988
1985
 
1989
- if include_assets:
1986
+ if include_assets and not skip_assets:
1990
1987
  assets = retrieve_data(args, release["assets_url"])
1991
1988
  if len(assets) > 0:
1992
1989
  # give release asset files somewhere to live & download them (not including source archives)
@@ -2008,9 +2005,11 @@ def backup_releases(args, repo_cwd, repository, repos_template, include_assets=F
2008
2005
  elif written_count == 0:
2009
2006
  logger.info("{0} releases unchanged, skipped write".format(total))
2010
2007
  else:
2011
- logger.info("Saved {0} of {1} releases to disk ({2} unchanged)".format(
2012
- written_count, total, total - written_count
2013
- ))
2008
+ logger.info(
2009
+ "Saved {0} of {1} releases to disk ({2} unchanged)".format(
2010
+ written_count, total, total - written_count
2011
+ )
2012
+ )
2014
2013
 
2015
2014
 
2016
2015
  def fetch_repository(
@@ -2024,9 +2023,12 @@ def fetch_repository(
2024
2023
  ):
2025
2024
  if bare_clone:
2026
2025
  if os.path.exists(local_dir):
2027
- clone_exists = subprocess.check_output(
2028
- ["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
2029
- ) == b"true\n"
2026
+ clone_exists = (
2027
+ subprocess.check_output(
2028
+ ["git", "rev-parse", "--is-bare-repository"], cwd=local_dir
2029
+ )
2030
+ == b"true\n"
2031
+ )
2030
2032
  else:
2031
2033
  clone_exists = False
2032
2034
  else:
@@ -2047,7 +2049,9 @@ def fetch_repository(
2047
2049
  )
2048
2050
  else:
2049
2051
  logger.info(
2050
- "Skipping {0} (repository not accessible - may be empty, private, or credentials invalid)".format(name)
2052
+ "Skipping {0} (repository not accessible - may be empty, private, or credentials invalid)".format(
2053
+ name
2054
+ )
2051
2055
  )
2052
2056
  return
2053
2057
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.56.0
3
+ Version: 0.58.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
@@ -80,8 +80,8 @@ CLI Help output::
80
80
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
81
81
  [--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
82
82
  [--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
83
- [--skip-prerelease] [--assets] [--attachments]
84
- [--exclude [REPOSITORY [REPOSITORY ...]]
83
+ [--skip-prerelease] [--assets] [--skip-assets-on [REPO ...]]
84
+ [--attachments] [--exclude [REPOSITORY [REPOSITORY ...]]
85
85
  [--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
86
86
  USER
87
87
 
@@ -163,6 +163,9 @@ CLI Help output::
163
163
  --skip-prerelease skip prerelease and draft versions; only applies if including releases
164
164
  --assets include assets alongside release information; only
165
165
  applies if including releases
166
+ --skip-assets-on [REPO ...]
167
+ skip asset downloads for these repositories (e.g.
168
+ --skip-assets-on repo1 owner/repo2)
166
169
  --attachments download user-attachments from issues and pull requests
167
170
  to issues/attachments/{issue_number}/ and
168
171
  pulls/attachments/{pull_number}/ directories
@@ -204,6 +207,37 @@ Customise the permissions for your use case, but for a personal account full bac
204
207
  **Repository permissions**: Read access to contents, issues, metadata, pull requests, and webhooks.
205
208
 
206
209
 
210
+ GitHub Apps
211
+ ~~~~~~~~~~~
212
+
213
+ GitHub Apps are ideal for organization backups in CI/CD. Tokens are scoped to specific repositories and expire after 1 hour.
214
+
215
+ **One-time setup:**
216
+
217
+ 1. Create a GitHub App at *Settings -> Developer Settings -> GitHub Apps -> New GitHub App*
218
+ 2. Set a name and homepage URL (can be any URL)
219
+ 3. Uncheck "Webhook > Active" (not needed for backups)
220
+ 4. Set permissions (same as fine-grained tokens above)
221
+ 5. Click "Create GitHub App", then note the **App ID** shown on the next page
222
+ 6. Under "Private keys", click "Generate a private key" and save the downloaded file
223
+ 7. Go to *Install App* in your app's settings
224
+ 8. Select the account/organization and which repositories to back up
225
+
226
+ **CI/CD usage with GitHub Actions:**
227
+
228
+ Store the App ID as a repository variable and the private key contents as a secret, then use ``actions/create-github-app-token``::
229
+
230
+ - uses: actions/create-github-app-token@v1
231
+ id: app-token
232
+ with:
233
+ app-id: ${{ vars.APP_ID }}
234
+ private-key: ${{ secrets.APP_PRIVATE_KEY }}
235
+
236
+ - run: github-backup myorg -t ${{ steps.app-token.outputs.token }} --as-app -o ./backup --all
237
+
238
+ Note: Installation tokens expire after 1 hour. For long-running backups, use a fine-grained personal access token instead.
239
+
240
+
207
241
  Prefer SSH
208
242
  ~~~~~~~~~~
209
243
 
@@ -391,6 +425,9 @@ Debug an error/block or incomplete backup into a temporary directory. Omit "incr
391
425
 
392
426
  github-backup -f $FINE_ACCESS_TOKEN -o /tmp/github-backup/ -l debug -P --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --pull-details --gists --starred-gists $GH_USER
393
427
 
428
+ Pipe a token from stdin to avoid storing it in environment variables or command history (Unix-like systems only)::
429
+
430
+ my-secret-manager get github-token | github-backup user -t file:///dev/stdin -o /backup --repositories
394
431
 
395
432
  Restoring from Backup
396
433
  =====================
@@ -0,0 +1,11 @@
1
+ github_backup/__init__.py,sha256=oIkJTCdzxBkcFHWQdIvMrmJih4tpnF1BnfoNPd1mB48,23
2
+ github_backup/__main__.py,sha256=3yorCiC63maaxbjdtaeAs-G-yAAxKSO3mprhGfa1Fho,271
3
+ github_backup/cli.py,sha256=zMUU0pTnh9KPqv8K--KNpUNf-yaA1qWeI6AdC1KVrn8,2361
4
+ github_backup/github_backup.py,sha256=Q3EcCwO7X8v5MPdWilx0zsJa8FLUINzSI8bjHw08O50,81287
5
+ github_backup-0.58.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
6
+ github_backup-0.58.0.dist-info/METADATA,sha256=wYRqf6V7hjtir_td4uYuK296O5KHeqpCPGhsr-ndnK8,25921
7
+ github_backup-0.58.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ github_backup-0.58.0.dist-info/entry_points.txt,sha256=cLPKK7MqC77QVb6HnT2mnyL_f-ciavJG_Y9vVHlCF68,57
9
+ github_backup-0.58.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
10
+ github_backup-0.58.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
+ github_backup-0.58.0.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- github_backup/__init__.py,sha256=pLF99dIRilisWyZd9CliPPWqIlV81Xy8XtGEMuThTaM,23
2
- github_backup/__main__.py,sha256=3yorCiC63maaxbjdtaeAs-G-yAAxKSO3mprhGfa1Fho,271
3
- github_backup/cli.py,sha256=zMUU0pTnh9KPqv8K--KNpUNf-yaA1qWeI6AdC1KVrn8,2361
4
- github_backup/github_backup.py,sha256=wlVrA50q5iP8BbJ_6WIIMEP7MKl_B3cKm2zvEtNYMS4,80777
5
- github_backup-0.56.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
6
- github_backup-0.56.0.dist-info/METADATA,sha256=0N-CqtE9r3amkSHGkrgR3YeG7wylthaM0Q2RDct5vbc,24230
7
- github_backup-0.56.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- github_backup-0.56.0.dist-info/entry_points.txt,sha256=cLPKK7MqC77QVb6HnT2mnyL_f-ciavJG_Y9vVHlCF68,57
9
- github_backup-0.56.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
10
- github_backup-0.56.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
- github_backup-0.56.0.dist-info/RECORD,,