github-backup 0.57.0__py3-none-any.whl → 0.59.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.57.0"
1
+ __version__ = "0.59.0"
github_backup/cli.py CHANGED
@@ -43,7 +43,7 @@ def main():
43
43
  if args.private and not get_auth(args):
44
44
  logger.warning(
45
45
  "The --private flag has no effect without authentication. "
46
- "Use -t/--token, -f/--token-fine, or -u/--username to authenticate."
46
+ "Use -t/--token or -f/--token-fine to authenticate."
47
47
  )
48
48
 
49
49
  if args.quiet:
@@ -7,11 +7,11 @@ import base64
7
7
  import calendar
8
8
  import codecs
9
9
  import errno
10
- import getpass
11
10
  import json
12
11
  import logging
13
12
  import os
14
13
  import platform
14
+ import random
15
15
  import re
16
16
  import select
17
17
  import socket
@@ -19,10 +19,10 @@ import ssl
19
19
  import subprocess
20
20
  import sys
21
21
  import time
22
+ from collections.abc import Generator
22
23
  from datetime import datetime
23
24
  from http.client import IncompleteRead
24
25
  from urllib.error import HTTPError, URLError
25
- from urllib.parse import quote as urlquote
26
26
  from urllib.parse import urlencode, urlparse
27
27
  from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
28
28
 
@@ -74,6 +74,9 @@ else:
74
74
  " 3. Debian/Ubuntu: apt-get install ca-certificates\n\n"
75
75
  )
76
76
 
77
+ # Retry configuration
78
+ MAX_RETRIES = 5
79
+
77
80
 
78
81
  def logging_subprocess(
79
82
  popenargs, stdout_log_level=logging.DEBUG, stderr_log_level=logging.ERROR, **kwargs
@@ -144,17 +147,6 @@ def mask_password(url, secret="*****"):
144
147
  def parse_args(args=None):
145
148
  parser = argparse.ArgumentParser(description="Backup a github account")
146
149
  parser.add_argument("user", metavar="USER", type=str, help="github username")
147
- parser.add_argument(
148
- "-u", "--username", dest="username", help="username for basic auth"
149
- )
150
- parser.add_argument(
151
- "-p",
152
- "--password",
153
- dest="password",
154
- help="password for basic auth. "
155
- "If a username is given but not a password, the "
156
- "password will be prompted for.",
157
- )
158
150
  parser.add_argument(
159
151
  "-t",
160
152
  "--token",
@@ -219,6 +211,13 @@ def parse_args(args=None):
219
211
  dest="all_starred",
220
212
  help="include starred repositories in backup [*]",
221
213
  )
214
+ parser.add_argument(
215
+ "--starred-skip-size-over",
216
+ type=int,
217
+ metavar="MB",
218
+ dest="starred_skip_size_over",
219
+ help="skip starred repositories larger than this size in MB",
220
+ )
222
221
  parser.add_argument(
223
222
  "--watched",
224
223
  action="store_true",
@@ -528,16 +527,6 @@ def get_auth(args, encode=True, for_git_cli=False):
528
527
  auth = args.token_classic
529
528
  else:
530
529
  auth = "x-access-token:" + args.token_classic
531
- elif args.username:
532
- if not args.password:
533
- args.password = getpass.getpass()
534
- if encode:
535
- password = args.password
536
- else:
537
- password = urlquote(args.password)
538
- auth = args.username + ":" + password
539
- elif args.password:
540
- raise Exception("You must specify a username for basic auth")
541
530
 
542
531
  if not auth:
543
532
  return None
@@ -603,170 +592,178 @@ def get_github_repo_url(args, repository):
603
592
  return repo_url
604
593
 
605
594
 
606
- def retrieve_data_gen(args, template, query_args=None, single_request=False):
607
- auth = get_auth(args, encode=not args.as_app)
608
- query_args = get_query_args(query_args)
609
- per_page = 100
610
- next_url = None
595
+ def calculate_retry_delay(attempt, headers):
596
+ """Calculate delay before next retry with exponential backoff."""
597
+ # Respect retry-after header if present
598
+ if retry_after := headers.get("retry-after"):
599
+ return int(retry_after)
611
600
 
612
- while True:
613
- if single_request:
614
- request_per_page = None
615
- else:
616
- request_per_page = per_page
601
+ # Respect rate limit reset time
602
+ if int(headers.get("x-ratelimit-remaining", 1)) < 1:
603
+ reset_time = int(headers.get("x-ratelimit-reset", 0))
604
+ return max(10, reset_time - calendar.timegm(time.gmtime()))
617
605
 
618
- request = _construct_request(
619
- request_per_page,
620
- query_args,
621
- next_url or template,
622
- auth,
623
- as_app=args.as_app,
624
- fine=True if args.token_fine is not None else False,
625
- ) # noqa
626
- r, errors = _get_response(request, auth, next_url or template)
606
+ # Exponential backoff with jitter for server errors (1s base, 120s max)
607
+ delay = min(1.0 * (2**attempt), 120.0)
608
+ return delay + random.uniform(0, delay * 0.1)
627
609
 
628
- status_code = int(r.getcode())
629
610
 
630
- # Handle DMCA takedown (HTTP 451) - raise exception to skip entire repository
631
- if status_code == 451:
632
- dmca_url = None
633
- try:
634
- response_data = json.loads(r.read().decode("utf-8"))
635
- dmca_url = response_data.get("block", {}).get("html_url")
636
- except Exception:
637
- pass
638
- raise RepositoryUnavailableError(
639
- "Repository unavailable due to legal reasons (HTTP 451)",
640
- dmca_url=dmca_url,
641
- )
611
+ def retrieve_data(args, template, query_args=None, paginated=True):
612
+ """
613
+ Fetch the data from GitHub API.
642
614
 
643
- # Check if we got correct data
644
- try:
645
- response = json.loads(r.read().decode("utf-8"))
646
- except IncompleteRead:
647
- logger.warning("Incomplete read error detected")
648
- read_error = True
649
- except json.decoder.JSONDecodeError:
650
- logger.warning("JSON decode error detected")
651
- read_error = True
652
- except TimeoutError:
653
- logger.warning("Tiemout error detected")
654
- read_error = True
655
- else:
656
- read_error = False
615
+ Handle both single requests and pagination with yield of individual dicts.
616
+ Handles throttling, retries, read errors, and DMCA takedowns.
617
+ """
618
+ query_args = query_args or {}
619
+ auth = get_auth(args, encode=not args.as_app)
620
+ per_page = 100
657
621
 
658
- # be gentle with API request limit and throttle requests if remaining requests getting low
659
- limit_remaining = int(r.headers.get("x-ratelimit-remaining", 0))
660
- if args.throttle_limit and limit_remaining <= args.throttle_limit:
661
- logger.info(
662
- "API request limit hit: {} requests left, pausing further requests for {}s".format(
663
- limit_remaining, args.throttle_pause
622
+ def _extract_next_page_url(link_header):
623
+ for link in link_header.split(","):
624
+ if 'rel="next"' in link:
625
+ return link[link.find("<") + 1:link.find(">")]
626
+ return None
627
+
628
+ def fetch_all() -> Generator[dict, None, None]:
629
+ next_url = None
630
+
631
+ while True:
632
+ # FIRST: Fetch response
633
+
634
+ for attempt in range(MAX_RETRIES):
635
+ request = _construct_request(
636
+ per_page=per_page if paginated else None,
637
+ query_args=query_args,
638
+ template=next_url or template,
639
+ auth=auth,
640
+ as_app=args.as_app,
641
+ fine=args.token_fine is not None,
664
642
  )
665
- )
666
- time.sleep(args.throttle_pause)
667
-
668
- retries = 0
669
- while retries < 3 and (status_code == 502 or read_error):
670
- logger.warning("API request failed. Retrying in 5 seconds")
671
- retries += 1
672
- time.sleep(5)
673
- request = _construct_request(
674
- request_per_page,
675
- query_args,
676
- next_url or template,
677
- auth,
678
- as_app=args.as_app,
679
- fine=True if args.token_fine is not None else False,
680
- ) # noqa
681
- r, errors = _get_response(request, auth, next_url or template)
682
-
683
- status_code = int(r.getcode())
684
- try:
685
- response = json.loads(r.read().decode("utf-8"))
686
- read_error = False
687
- except IncompleteRead:
688
- logger.warning("Incomplete read error detected")
689
- read_error = True
690
- except json.decoder.JSONDecodeError:
691
- logger.warning("JSON decode error detected")
692
- read_error = True
693
- except TimeoutError:
694
- logger.warning("Tiemout error detected")
695
- read_error = True
696
-
697
- if status_code != 200:
698
- template = "API request returned HTTP {0}: {1}"
699
- errors.append(template.format(status_code, r.reason))
700
- raise Exception(", ".join(errors))
701
-
702
- if read_error:
703
- template = "API request problem reading response for {0}"
704
- errors.append(template.format(request))
705
- raise Exception(", ".join(errors))
706
-
707
- if len(errors) == 0:
708
- if type(response) is list:
709
- for resp in response:
710
- yield resp
711
- # Parse Link header for next page URL (cursor-based pagination)
712
- link_header = r.headers.get("Link", "")
713
- next_url = None
714
- if link_header:
715
- # Parse Link header: <https://api.github.com/...?per_page=100&after=cursor>; rel="next"
716
- for link in link_header.split(","):
717
- if 'rel="next"' in link:
718
- next_url = link[link.find("<") + 1 : link.find(">")]
719
- break
720
- if not next_url:
721
- break
722
- elif type(response) is dict and single_request:
723
- yield response
643
+ http_response = make_request_with_retry(request, auth)
644
+
645
+ match http_response.getcode():
646
+ case 200:
647
+ # Success - Parse JSON response
648
+ try:
649
+ response = json.loads(http_response.read().decode("utf-8"))
650
+ break # Exit retry loop and handle the data returned
651
+ except (
652
+ IncompleteRead,
653
+ json.decoder.JSONDecodeError,
654
+ TimeoutError,
655
+ ) as e:
656
+ logger.warning(f"{type(e).__name__} reading response")
657
+ if attempt < MAX_RETRIES - 1:
658
+ delay = calculate_retry_delay(attempt, {})
659
+ logger.warning(
660
+ f"Retrying in {delay:.1f}s (attempt {attempt + 1}/{MAX_RETRIES})"
661
+ )
662
+ time.sleep(delay)
663
+ continue # Next retry attempt
664
+
665
+ case 451:
666
+ # DMCA takedown - extract URL if available, then raise
667
+ dmca_url = None
668
+ try:
669
+ response_data = json.loads(
670
+ http_response.read().decode("utf-8")
671
+ )
672
+ dmca_url = response_data.get("block", {}).get("html_url")
673
+ except Exception:
674
+ pass
675
+ raise RepositoryUnavailableError(
676
+ "Repository unavailable due to legal reasons (HTTP 451)",
677
+ dmca_url=dmca_url,
678
+ )
724
679
 
725
- if len(errors) > 0:
726
- raise Exception(", ".join(errors))
680
+ case _:
681
+ raise Exception(
682
+ f"API request returned HTTP {http_response.getcode()}: {http_response.reason}"
683
+ )
684
+ else:
685
+ logger.error(
686
+ f"Failed to read response after {MAX_RETRIES} attempts for {next_url or template}"
687
+ )
688
+ raise Exception(
689
+ f"Failed to read response after {MAX_RETRIES} attempts for {next_url or template}"
690
+ )
727
691
 
728
- if single_request:
729
- break
692
+ # SECOND: Process and paginate
730
693
 
694
+ # Pause before next request if rate limit is low
695
+ if (
696
+ remaining := int(http_response.headers.get("x-ratelimit-remaining", 0))
697
+ ) <= (args.throttle_limit or 0):
698
+ if args.throttle_limit:
699
+ logger.info(
700
+ f"Throttling: {remaining} requests left, pausing {args.throttle_pause}s"
701
+ )
702
+ time.sleep(args.throttle_pause)
731
703
 
732
- def retrieve_data(args, template, query_args=None, single_request=False):
733
- return list(retrieve_data_gen(args, template, query_args, single_request))
704
+ # Yield results
705
+ if isinstance(response, list):
706
+ yield from response
707
+ elif isinstance(response, dict):
708
+ yield response
734
709
 
710
+ # Check for more pages
711
+ if not paginated or not (
712
+ next_url := _extract_next_page_url(
713
+ http_response.headers.get("Link", "")
714
+ )
715
+ ):
716
+ break # No more data
735
717
 
736
- def get_query_args(query_args=None):
737
- if not query_args:
738
- query_args = {}
739
- return query_args
718
+ return list(fetch_all())
740
719
 
741
720
 
742
- def _get_response(request, auth, template):
743
- retry_timeout = 3
744
- errors = []
745
- # We'll make requests in a loop so we can
746
- # delay and retry in the case of rate-limiting
747
- while True:
748
- should_continue = False
721
+ def make_request_with_retry(request, auth):
722
+ """Make HTTP request with automatic retry for transient errors."""
723
+
724
+ def is_retryable_status(status_code, headers):
725
+ # Server errors are always retryable
726
+ if status_code in (500, 502, 503, 504):
727
+ return True
728
+ # Rate limit (403/429) is retryable if limit exhausted
729
+ if status_code in (403, 429):
730
+ return int(headers.get("x-ratelimit-remaining", 1)) < 1
731
+ return False
732
+
733
+ for attempt in range(MAX_RETRIES):
749
734
  try:
750
- r = urlopen(request, context=https_ctx)
735
+ return urlopen(request, context=https_ctx)
736
+
751
737
  except HTTPError as exc:
752
- errors, should_continue = _request_http_error(exc, auth, errors) # noqa
753
- r = exc
754
- except URLError as e:
755
- logger.warning(e.reason)
756
- should_continue, retry_timeout = _request_url_error(template, retry_timeout)
757
- if not should_continue:
758
- raise
759
- except socket.error as e:
760
- logger.warning(e.strerror)
761
- should_continue, retry_timeout = _request_url_error(template, retry_timeout)
762
- if not should_continue:
738
+ # HTTPError can be used as a response-like object
739
+ if not is_retryable_status(exc.code, exc.headers):
740
+ raise # Non-retryable error
741
+
742
+ if attempt >= MAX_RETRIES - 1:
743
+ logger.error(f"HTTP {exc.code} failed after {MAX_RETRIES} attempts")
763
744
  raise
764
745
 
765
- if should_continue:
766
- continue
746
+ delay = calculate_retry_delay(attempt, exc.headers)
747
+ logger.warning(
748
+ f"HTTP {exc.code}, retrying in {delay:.1f}s "
749
+ f"(attempt {attempt + 1}/{MAX_RETRIES})"
750
+ )
751
+ if auth is None and exc.code in (403, 429):
752
+ logger.info("Hint: Authenticate to raise your GitHub rate limit")
753
+ time.sleep(delay)
767
754
 
768
- break
769
- return r, errors
755
+ except (URLError, socket.error) as e:
756
+ if attempt >= MAX_RETRIES - 1:
757
+ logger.error(f"Connection error failed after {MAX_RETRIES} attempts: {e}")
758
+ raise
759
+ delay = calculate_retry_delay(attempt, {})
760
+ logger.warning(
761
+ f"Connection error: {e}, retrying in {delay:.1f}s "
762
+ f"(attempt {attempt + 1}/{MAX_RETRIES})"
763
+ )
764
+ time.sleep(delay)
765
+
766
+ raise Exception(f"Request failed after {MAX_RETRIES} attempts") # pragma: no cover
770
767
 
771
768
 
772
769
  def _construct_request(per_page, query_args, template, auth, as_app=None, fine=False):
@@ -808,52 +805,6 @@ def _construct_request(per_page, query_args, template, auth, as_app=None, fine=F
808
805
  return request
809
806
 
810
807
 
811
- def _request_http_error(exc, auth, errors):
812
- # HTTPError behaves like a Response so we can
813
- # check the status code and headers to see exactly
814
- # what failed.
815
-
816
- should_continue = False
817
- headers = exc.headers
818
- limit_remaining = int(headers.get("x-ratelimit-remaining", 0))
819
-
820
- if exc.code == 403 and limit_remaining < 1:
821
- # The X-RateLimit-Reset header includes a
822
- # timestamp telling us when the limit will reset
823
- # so we can calculate how long to wait rather
824
- # than inefficiently polling:
825
- gm_now = calendar.timegm(time.gmtime())
826
- reset = int(headers.get("x-ratelimit-reset", 0)) or gm_now
827
- # We'll never sleep for less than 10 seconds:
828
- delta = max(10, reset - gm_now)
829
-
830
- limit = headers.get("x-ratelimit-limit")
831
- logger.warning(
832
- "Exceeded rate limit of {} requests; waiting {} seconds to reset".format(
833
- limit, delta
834
- )
835
- ) # noqa
836
-
837
- if auth is None:
838
- logger.info("Hint: Authenticate to raise your GitHub rate limit")
839
-
840
- time.sleep(delta)
841
- should_continue = True
842
- return errors, should_continue
843
-
844
-
845
- def _request_url_error(template, retry_timeout):
846
- # In case of a connection timing out, we can retry a few time
847
- # But we won't crash and not back-up the rest now
848
- logger.info("'{}' timed out".format(template))
849
- retry_timeout -= 1
850
-
851
- if retry_timeout >= 0:
852
- return True, retry_timeout
853
-
854
- raise Exception("'{}' timed out to much, skipping!".format(template))
855
-
856
-
857
808
  class S3HTTPRedirectHandler(HTTPRedirectHandler):
858
809
  """
859
810
  A subclassed redirect handler for downloading Github assets from S3.
@@ -1503,7 +1454,7 @@ def download_attachments(
1503
1454
 
1504
1455
  def get_authenticated_user(args):
1505
1456
  template = "https://{0}/user".format(get_github_api_host(args))
1506
- data = retrieve_data(args, template, single_request=True)
1457
+ data = retrieve_data(args, template, paginated=False)
1507
1458
  return data[0]
1508
1459
 
1509
1460
 
@@ -1517,7 +1468,7 @@ def check_git_lfs_install():
1517
1468
 
1518
1469
  def retrieve_repositories(args, authenticated_user):
1519
1470
  logger.info("Retrieving repositories")
1520
- single_request = False
1471
+ paginated = True
1521
1472
  if args.user == authenticated_user["login"]:
1522
1473
  # we must use the /user/repos API to be able to access private repos
1523
1474
  template = "https://{0}/user/repos".format(get_github_api_host(args))
@@ -1540,16 +1491,16 @@ def retrieve_repositories(args, authenticated_user):
1540
1491
  repo_path = args.repository
1541
1492
  else:
1542
1493
  repo_path = "{0}/{1}".format(args.user, args.repository)
1543
- single_request = True
1494
+ paginated = False
1544
1495
  template = "https://{0}/repos/{1}".format(get_github_api_host(args), repo_path)
1545
1496
 
1546
- repos = retrieve_data(args, template, single_request=single_request)
1497
+ repos = retrieve_data(args, template, paginated=paginated)
1547
1498
 
1548
1499
  if args.all_starred:
1549
1500
  starred_template = "https://{0}/users/{1}/starred".format(
1550
1501
  get_github_api_host(args), args.user
1551
1502
  )
1552
- starred_repos = retrieve_data(args, starred_template, single_request=False)
1503
+ starred_repos = retrieve_data(args, starred_template)
1553
1504
  # flag each repo as starred for downstream processing
1554
1505
  for item in starred_repos:
1555
1506
  item.update({"is_starred": True})
@@ -1559,7 +1510,7 @@ def retrieve_repositories(args, authenticated_user):
1559
1510
  gists_template = "https://{0}/users/{1}/gists".format(
1560
1511
  get_github_api_host(args), args.user
1561
1512
  )
1562
- gists = retrieve_data(args, gists_template, single_request=False)
1513
+ gists = retrieve_data(args, gists_template)
1563
1514
  # flag each repo as a gist for downstream processing
1564
1515
  for item in gists:
1565
1516
  item.update({"is_gist": True})
@@ -1578,9 +1529,7 @@ def retrieve_repositories(args, authenticated_user):
1578
1529
  starred_gists_template = "https://{0}/gists/starred".format(
1579
1530
  get_github_api_host(args)
1580
1531
  )
1581
- starred_gists = retrieve_data(
1582
- args, starred_gists_template, single_request=False
1583
- )
1532
+ starred_gists = retrieve_data(args, starred_gists_template)
1584
1533
  # flag each repo as a starred gist for downstream processing
1585
1534
  for item in starred_gists:
1586
1535
  item.update({"is_gist": True, "is_starred": True})
@@ -1628,6 +1577,25 @@ def filter_repositories(args, unfiltered_repositories):
1628
1577
  ]
1629
1578
  if args.skip_archived:
1630
1579
  repositories = [r for r in repositories if not r.get("archived")]
1580
+ if args.starred_skip_size_over is not None:
1581
+ if args.starred_skip_size_over <= 0:
1582
+ logger.warning(
1583
+ "--starred-skip-size-over must be greater than 0, ignoring"
1584
+ )
1585
+ else:
1586
+ size_limit_kb = args.starred_skip_size_over * 1024
1587
+ filtered = []
1588
+ for r in repositories:
1589
+ if r.get("is_starred") and r.get("size", 0) > size_limit_kb:
1590
+ size_mb = r.get("size", 0) / 1024
1591
+ logger.info(
1592
+ "Skipping starred repo {0} ({1:.0f} MB) due to --starred-skip-size-over {2}".format(
1593
+ r.get("full_name", r.get("name")), size_mb, args.starred_skip_size_over
1594
+ )
1595
+ )
1596
+ else:
1597
+ filtered.append(r)
1598
+ repositories = filtered
1631
1599
  if args.exclude:
1632
1600
  repositories = [
1633
1601
  r for r in repositories if "name" not in r or r["name"] not in args.exclude
@@ -1849,14 +1817,14 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1849
1817
  pull_states = ["open", "closed"]
1850
1818
  for pull_state in pull_states:
1851
1819
  query_args["state"] = pull_state
1852
- _pulls = retrieve_data_gen(args, _pulls_template, query_args=query_args)
1820
+ _pulls = retrieve_data(args, _pulls_template, query_args=query_args)
1853
1821
  for pull in _pulls:
1854
1822
  if args.since and pull["updated_at"] < args.since:
1855
1823
  break
1856
1824
  if not args.since or pull["updated_at"] >= args.since:
1857
1825
  pulls[pull["number"]] = pull
1858
1826
  else:
1859
- _pulls = retrieve_data_gen(args, _pulls_template, query_args=query_args)
1827
+ _pulls = retrieve_data(args, _pulls_template, query_args=query_args)
1860
1828
  for pull in _pulls:
1861
1829
  if args.since and pull["updated_at"] < args.since:
1862
1830
  break
@@ -1864,7 +1832,7 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1864
1832
  pulls[pull["number"]] = retrieve_data(
1865
1833
  args,
1866
1834
  _pulls_template + "/{}".format(pull["number"]),
1867
- single_request=True,
1835
+ paginated=False,
1868
1836
  )[0]
1869
1837
 
1870
1838
  logger.info("Saving {0} pull requests to disk".format(len(list(pulls.keys()))))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.57.0
3
+ Version: 0.59.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
@@ -66,23 +66,26 @@ Show the CLI help output::
66
66
 
67
67
  CLI Help output::
68
68
 
69
- github-backup [-h] [-u USERNAME] [-p PASSWORD] [-t TOKEN_CLASSIC]
70
- [-f TOKEN_FINE] [--as-app] [-o OUTPUT_DIRECTORY]
71
- [-l LOG_LEVEL] [-i] [--starred] [--all-starred]
72
- [--watched] [--followers] [--following] [--all] [--issues]
73
- [--issue-comments] [--issue-events] [--pulls]
69
+ github-backup [-h] [-t TOKEN_CLASSIC] [-f TOKEN_FINE] [-q] [--as-app]
70
+ [-o OUTPUT_DIRECTORY] [-l LOG_LEVEL] [-i]
71
+ [--incremental-by-files]
72
+ [--starred] [--all-starred] [--starred-skip-size-over MB]
73
+ [--watched] [--followers] [--following] [--all]
74
+ [--issues] [--issue-comments] [--issue-events] [--pulls]
74
75
  [--pull-comments] [--pull-commits] [--pull-details]
75
76
  [--labels] [--hooks] [--milestones] [--repositories]
76
- [--bare] [--lfs] [--wikis] [--gists] [--starred-gists]
77
- [--skip-archived] [--skip-existing] [-L [LANGUAGES ...]]
78
- [-N NAME_REGEX] [-H GITHUB_HOST] [-O] [-R REPOSITORY]
79
- [-P] [-F] [--prefer-ssh] [-v]
77
+ [--bare] [--no-prune] [--lfs] [--wikis] [--gists]
78
+ [--starred-gists] [--skip-archived] [--skip-existing]
79
+ [-L [LANGUAGES ...]] [-N NAME_REGEX] [-H GITHUB_HOST]
80
+ [-O] [-R REPOSITORY] [-P] [-F] [--prefer-ssh] [-v]
80
81
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
81
82
  [--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
82
83
  [--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
83
- [--skip-prerelease] [--assets] [--skip-assets-on [REPO ...]]
84
- [--attachments] [--exclude [REPOSITORY [REPOSITORY ...]]
85
- [--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
84
+ [--skip-prerelease] [--assets]
85
+ [--skip-assets-on [SKIP_ASSETS_ON ...]] [--attachments]
86
+ [--throttle-limit THROTTLE_LIMIT]
87
+ [--throttle-pause THROTTLE_PAUSE]
88
+ [--exclude [EXCLUDE ...]]
86
89
  USER
87
90
 
88
91
  Backup a github account
@@ -90,29 +93,29 @@ CLI Help output::
90
93
  positional arguments:
91
94
  USER github username
92
95
 
93
- optional arguments:
96
+ options:
94
97
  -h, --help show this help message and exit
95
- -u USERNAME, --username USERNAME
96
- username for basic auth
97
- -p PASSWORD, --password PASSWORD
98
- password for basic auth. If a username is given but
99
- not a password, the password will be prompted for.
100
- -f TOKEN_FINE, --token-fine TOKEN_FINE
101
- fine-grained personal access token or path to token
102
- (file://...)
103
- -t TOKEN_CLASSIC, --token TOKEN_CLASSIC
98
+ -t, --token TOKEN_CLASSIC
104
99
  personal access, OAuth, or JSON Web token, or path to
105
100
  token (file://...)
101
+ -f, --token-fine TOKEN_FINE
102
+ fine-grained personal access token (github_pat_....),
103
+ or path to token (file://...)
104
+ -q, --quiet supress log messages less severe than warning, e.g.
105
+ info
106
106
  --as-app authenticate as github app instead of as a user.
107
- -o OUTPUT_DIRECTORY, --output-directory OUTPUT_DIRECTORY
107
+ -o, --output-directory OUTPUT_DIRECTORY
108
108
  directory at which to backup the repositories
109
- -l LOG_LEVEL, --log-level LOG_LEVEL
109
+ -l, --log-level LOG_LEVEL
110
110
  log level to use (default: info, possible levels:
111
111
  debug, info, warning, error, critical)
112
112
  -i, --incremental incremental backup
113
- --incremental-by-files incremental backup using modified time of files
113
+ --incremental-by-files
114
+ incremental backup based on modification date of files
114
115
  --starred include JSON output of starred repositories in backup
115
116
  --all-starred include starred repositories in backup [*]
117
+ --starred-skip-size-over MB
118
+ skip starred repositories larger than this size in MB
116
119
  --watched include JSON output of watched repositories in backup
117
120
  --followers include JSON output of followers in backup
118
121
  --following include JSON output of following users in backup
@@ -130,20 +133,22 @@ CLI Help output::
130
133
  --milestones include milestones in backup
131
134
  --repositories include repository clone in backup
132
135
  --bare clone bare repositories
136
+ --no-prune disable prune option for git fetch
133
137
  --lfs clone LFS repositories (requires Git LFS to be
134
138
  installed, https://git-lfs.github.com) [*]
135
139
  --wikis include wiki clone in backup
136
140
  --gists include gists in backup [*]
137
141
  --starred-gists include starred gists in backup [*]
142
+ --skip-archived skip project if it is archived
138
143
  --skip-existing skip project if a backup directory exists
139
- -L [LANGUAGES [LANGUAGES ...]], --languages [LANGUAGES [LANGUAGES ...]]
144
+ -L, --languages [LANGUAGES ...]
140
145
  only allow these languages
141
- -N NAME_REGEX, --name-regex NAME_REGEX
146
+ -N, --name-regex NAME_REGEX
142
147
  python regex to match names against
143
- -H GITHUB_HOST, --github-host GITHUB_HOST
148
+ -H, --github-host GITHUB_HOST
144
149
  GitHub Enterprise hostname
145
150
  -O, --organization whether or not this is an organization user
146
- -R REPOSITORY, --repository REPOSITORY
151
+ -R, --repository REPOSITORY
147
152
  name of repository to limit backup to
148
153
  -P, --private include private repositories [*]
149
154
  -F, --fork include forked repositories [*]
@@ -158,19 +163,16 @@ CLI Help output::
158
163
  --releases include release information, not including assets or
159
164
  binaries
160
165
  --latest-releases NUMBER_OF_LATEST_RELEASES
161
- include certain number of the latest releases;
162
- only applies if including releases
163
- --skip-prerelease skip prerelease and draft versions; only applies if including releases
166
+ include certain number of the latest releases; only
167
+ applies if including releases
168
+ --skip-prerelease skip prerelease and draft versions; only applies if
169
+ including releases
164
170
  --assets include assets alongside release information; only
165
171
  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)
169
- --attachments download user-attachments from issues and pull requests
170
- to issues/attachments/{issue_number}/ and
171
- pulls/attachments/{pull_number}/ directories
172
- --exclude [REPOSITORY [REPOSITORY ...]]
173
- names of repositories to exclude from backup.
172
+ --skip-assets-on [SKIP_ASSETS_ON ...]
173
+ skip asset downloads for these repositories
174
+ --attachments download user-attachments from issues and pull
175
+ requests
174
176
  --throttle-limit THROTTLE_LIMIT
175
177
  start throttling of GitHub API requests after this
176
178
  amount of API requests remain
@@ -178,6 +180,8 @@ CLI Help output::
178
180
  wait this amount of seconds when API request
179
181
  throttling is active (default: 30.0, requires
180
182
  --throttle-limit to be set)
183
+ --exclude [EXCLUDE ...]
184
+ names of repositories to exclude
181
185
 
182
186
 
183
187
  Usage Details
@@ -186,13 +190,13 @@ Usage Details
186
190
  Authentication
187
191
  --------------
188
192
 
189
- **Password-based authentication** will fail if you have two-factor authentication enabled, and will `be deprecated <https://github.blog/2023-03-09-raising-the-bar-for-software-security-github-2fa-begins-march-13/>`_ by 2023 EOY.
193
+ GitHub requires token-based authentication for API access. Password authentication was `removed in November 2020 <https://developer.github.com/changes/2020-02-14-deprecating-password-auth/>`_.
190
194
 
191
- ``--username`` is used for basic password authentication and separate from the positional argument ``USER``, which specifies the user account you wish to back up.
195
+ The positional argument ``USER`` specifies the user or organization account you wish to back up.
192
196
 
193
- **Classic tokens** are `slightly less secure <https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#personal-access-tokens-classic>`_ as they provide very coarse-grained permissions.
197
+ **Fine-grained tokens** (``-f TOKEN_FINE``) are recommended for most use cases, especially long-running backups (e.g. cron jobs), as they provide precise permission control.
194
198
 
195
- If you need authentication for long-running backups (e.g. for a cron job) it is recommended to use **fine-grained personal access token** ``-f TOKEN_FINE``.
199
+ **Classic tokens** (``-t TOKEN``) are `slightly less secure <https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#personal-access-tokens-classic>`_ as they provide very coarse-grained permissions.
196
200
 
197
201
 
198
202
  Fine Tokens
@@ -320,10 +324,20 @@ All is not everything
320
324
 
321
325
  The ``--all`` argument does not include: cloning private repos (``-P, --private``), cloning forks (``-F, --fork``), cloning starred repositories (``--all-starred``), ``--pull-details``, cloning LFS repositories (``--lfs``), cloning gists (``--gists``) or cloning starred gist repos (``--starred-gists``). See examples for more.
322
326
 
323
- Cloning all starred size
324
- ------------------------
327
+ Starred repository size
328
+ -----------------------
329
+
330
+ Using the ``--all-starred`` argument to clone all starred repositories may use a large amount of storage space.
331
+
332
+ To see your starred repositories sorted by size (requires `GitHub CLI <https://cli.github.com>`_)::
333
+
334
+ gh api user/starred --paginate --jq 'sort_by(-.size)[]|"\(.full_name) \(.size/1024|round)MB"'
335
+
336
+ To limit which starred repositories are cloned, use ``--starred-skip-size-over SIZE`` where SIZE is in MB. For example, ``--starred-skip-size-over 500`` will skip any starred repository where the git repository size (code and history) exceeds 500 MB. Note that this size limit only applies to the repository itself, not issues, release assets or other metadata. This filter only affects starred repositories; your own repositories are always included regardless of size.
337
+
338
+ For finer control, avoid using ``--assets`` with starred repos, or use ``--skip-assets-on`` for specific repositories with large release binaries.
325
339
 
326
- Using the ``--all-starred`` argument to clone all starred repositories may use a large amount of storage space, especially if ``--all`` or more arguments are used. e.g. commonly starred repos can have tens of thousands of issues, many large assets and the repo itself etc. Consider just storing links to starred repos in JSON format with ``--starred``.
340
+ Alternatively, consider just storing links to starred repos in JSON format with ``--starred``.
327
341
 
328
342
  Incremental Backup
329
343
  ------------------
@@ -0,0 +1,11 @@
1
+ github_backup/__init__.py,sha256=HMyZ2jNndR4_RS4EcSAHrVoKBifJVKSLuqmMuKnl_JI,23
2
+ github_backup/__main__.py,sha256=3yorCiC63maaxbjdtaeAs-G-yAAxKSO3mprhGfa1Fho,271
3
+ github_backup/cli.py,sha256=ifnOne5AaEI7laofgIq3geURJtvbA-bzxSQgLcmZgd0,2345
4
+ github_backup/github_backup.py,sha256=VkUaqxAMT3ooQ4xGp54xpUtAJL4h4zZ8b_-EtrFi2T4,81615
5
+ github_backup-0.59.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
6
+ github_backup-0.59.0.dist-info/METADATA,sha256=_IyD8iBRAXp-Kkz1Ru0pkwjzVNDECjB6jKN6IR3n-Sk,26421
7
+ github_backup-0.59.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ github_backup-0.59.0.dist-info/entry_points.txt,sha256=cLPKK7MqC77QVb6HnT2mnyL_f-ciavJG_Y9vVHlCF68,57
9
+ github_backup-0.59.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
10
+ github_backup-0.59.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
+ github_backup-0.59.0.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- github_backup/__init__.py,sha256=jmnoPljZ40w5Zc7DEZpxkJSfC9yHbRjV50qzvnfEbdI,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=_P0WVPpZpyzs3PPkwtmIWDu2mkmeGXd2EePymeEtOV0,81890
5
- github_backup-0.57.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
6
- github_backup-0.57.0.dist-info/METADATA,sha256=4Jni1TDR6oVFdIKCzjy6hacTh1TQrigaAeXVyidL-As,25921
7
- github_backup-0.57.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- github_backup-0.57.0.dist-info/entry_points.txt,sha256=cLPKK7MqC77QVb6HnT2mnyL_f-ciavJG_Y9vVHlCF68,57
9
- github_backup-0.57.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
10
- github_backup-0.57.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
11
- github_backup-0.57.0.dist-info/RECORD,,