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 +1 -1
- github_backup/cli.py +1 -1
- github_backup/github_backup.py +193 -225
- {github_backup-0.57.0.dist-info → github_backup-0.59.0.dist-info}/METADATA +62 -48
- github_backup-0.59.0.dist-info/RECORD +11 -0
- github_backup-0.57.0.dist-info/RECORD +0 -11
- {github_backup-0.57.0.dist-info → github_backup-0.59.0.dist-info}/WHEEL +0 -0
- {github_backup-0.57.0.dist-info → github_backup-0.59.0.dist-info}/entry_points.txt +0 -0
- {github_backup-0.57.0.dist-info → github_backup-0.59.0.dist-info}/licenses/LICENSE.txt +0 -0
- {github_backup-0.57.0.dist-info → github_backup-0.59.0.dist-info}/top_level.txt +0 -0
- {github_backup-0.57.0.dist-info → github_backup-0.59.0.dist-info}/zip-safe +0 -0
github_backup/__init__.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "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
|
|
46
|
+
"Use -t/--token or -f/--token-fine to authenticate."
|
|
47
47
|
)
|
|
48
48
|
|
|
49
49
|
if args.quiet:
|
github_backup/github_backup.py
CHANGED
|
@@ -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
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
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
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
726
|
-
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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
|
-
|
|
737
|
-
if not query_args:
|
|
738
|
-
query_args = {}
|
|
739
|
-
return query_args
|
|
718
|
+
return list(fetch_all())
|
|
740
719
|
|
|
741
720
|
|
|
742
|
-
def
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
735
|
+
return urlopen(request, context=https_ctx)
|
|
736
|
+
|
|
751
737
|
except HTTPError as exc:
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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
|
-
|
|
766
|
-
|
|
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
|
-
|
|
769
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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] [-
|
|
70
|
-
[-
|
|
71
|
-
[-
|
|
72
|
-
[--
|
|
73
|
-
[--
|
|
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] [--
|
|
77
|
-
[--
|
|
78
|
-
[-
|
|
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]
|
|
84
|
-
[--
|
|
85
|
-
[--throttle-limit THROTTLE_LIMIT]
|
|
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
|
-
|
|
96
|
+
options:
|
|
94
97
|
-h, --help show this help message and exit
|
|
95
|
-
-
|
|
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
|
|
107
|
+
-o, --output-directory OUTPUT_DIRECTORY
|
|
108
108
|
directory at which to backup the repositories
|
|
109
|
-
-l
|
|
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
|
|
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
|
|
144
|
+
-L, --languages [LANGUAGES ...]
|
|
140
145
|
only allow these languages
|
|
141
|
-
-N
|
|
146
|
+
-N, --name-regex NAME_REGEX
|
|
142
147
|
python regex to match names against
|
|
143
|
-
-H
|
|
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
|
|
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
|
-
|
|
163
|
-
--skip-prerelease skip prerelease and draft versions; only applies if
|
|
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 [
|
|
167
|
-
skip asset downloads for these repositories
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
+
The positional argument ``USER`` specifies the user or organization account you wish to back up.
|
|
192
196
|
|
|
193
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|