github-backup 0.50.2__py3-none-any.whl → 0.51.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.50.2"
1
+ __version__ = "0.51.0"
@@ -420,6 +420,12 @@ def parse_args(args=None):
420
420
  dest="include_assets",
421
421
  help="include assets alongside release information; only applies if including releases",
422
422
  )
423
+ parser.add_argument(
424
+ "--attachments",
425
+ action="store_true",
426
+ dest="include_attachments",
427
+ help="download user-attachments from issues and pull requests",
428
+ )
423
429
  parser.add_argument(
424
430
  "--throttle-limit",
425
431
  dest="throttle_limit",
@@ -578,10 +584,15 @@ def retrieve_data_gen(args, template, query_args=None, single_request=False):
578
584
  page = 0
579
585
 
580
586
  while True:
581
- page = page + 1
587
+ if single_request:
588
+ request_page, request_per_page = None, None
589
+ else:
590
+ page = page + 1
591
+ request_page, request_per_page = page, per_page
592
+
582
593
  request = _construct_request(
583
- per_page,
584
- page,
594
+ request_per_page,
595
+ request_page,
585
596
  query_args,
586
597
  template,
587
598
  auth,
@@ -715,14 +726,22 @@ def _get_response(request, auth, template):
715
726
  def _construct_request(
716
727
  per_page, page, query_args, template, auth, as_app=None, fine=False
717
728
  ):
718
- querystring = urlencode(
719
- dict(
720
- list({"per_page": per_page, "page": page}.items())
721
- + list(query_args.items())
722
- )
723
- )
729
+ all_query_args = {}
730
+ if per_page:
731
+ all_query_args["per_page"] = per_page
732
+ if page:
733
+ all_query_args["page"] = page
734
+ if query_args:
735
+ all_query_args.update(query_args)
736
+
737
+ request_url = template
738
+ if all_query_args:
739
+ querystring = urlencode(all_query_args)
740
+ request_url = template + "?" + querystring
741
+ else:
742
+ querystring = ""
724
743
 
725
- request = Request(template + "?" + querystring)
744
+ request = Request(request_url)
726
745
  if auth is not None:
727
746
  if not as_app:
728
747
  if fine:
@@ -735,7 +754,11 @@ def _construct_request(
735
754
  request.add_header(
736
755
  "Accept", "application/vnd.github.machine-man-preview+json"
737
756
  )
738
- logger.info("Requesting {}?{}".format(template, querystring))
757
+
758
+ log_url = template
759
+ if querystring:
760
+ log_url += "?" + querystring
761
+ logger.info("Requesting {}".format(log_url))
739
762
  return request
740
763
 
741
764
 
@@ -797,7 +820,9 @@ class S3HTTPRedirectHandler(HTTPRedirectHandler):
797
820
  request = super(S3HTTPRedirectHandler, self).redirect_request(
798
821
  req, fp, code, msg, headers, newurl
799
822
  )
800
- del request.headers["Authorization"]
823
+ # Only delete Authorization header if it exists (attachments may not have it)
824
+ if "Authorization" in request.headers:
825
+ del request.headers["Authorization"]
801
826
  return request
802
827
 
803
828
 
@@ -850,6 +875,594 @@ def download_file(url, path, auth, as_app=False, fine=False):
850
875
  )
851
876
 
852
877
 
878
+ def download_attachment_file(url, path, auth, as_app=False, fine=False):
879
+ """Download attachment file directly (not via GitHub API).
880
+
881
+ Similar to download_file() but for direct file URLs, not API endpoints.
882
+ Attachment URLs (user-images, user-attachments) are direct downloads,
883
+ not API endpoints, so we skip _construct_request() which adds API params.
884
+
885
+ URL Format Support & Authentication Requirements:
886
+
887
+ | URL Format | Auth Required | Notes |
888
+ |----------------------------------------------|---------------|--------------------------|
889
+ | github.com/user-attachments/assets/* | Private only | Modern format (2024+) |
890
+ | github.com/user-attachments/files/* | Private only | Modern format (2024+) |
891
+ | user-images.githubusercontent.com/* | No (public) | Legacy CDN, all eras |
892
+ | private-user-images.githubusercontent.com/* | JWT in URL | Legacy private (5min) |
893
+ | github.com/{owner}/{repo}/files/* | Repo filter | Old repo files |
894
+
895
+ - Modern user-attachments: Requires GitHub token auth for private repos
896
+ - Legacy public CDN: No auth needed/accepted (returns 400 with auth header)
897
+ - Legacy private CDN: Uses JWT token embedded in URL, no GitHub token needed
898
+ - Repo files: Filtered to current repository only during extraction
899
+
900
+ Returns dict with metadata:
901
+ - success: bool
902
+ - http_status: int (200, 404, etc.)
903
+ - content_type: str or None
904
+ - original_filename: str or None (from Content-Disposition)
905
+ - size_bytes: int or None
906
+ - error: str or None
907
+ """
908
+ import re
909
+ from datetime import datetime, timezone
910
+
911
+ metadata = {
912
+ "url": url,
913
+ "success": False,
914
+ "http_status": None,
915
+ "content_type": None,
916
+ "original_filename": None,
917
+ "size_bytes": None,
918
+ "downloaded_at": datetime.now(timezone.utc).isoformat(),
919
+ "error": None,
920
+ }
921
+
922
+ if os.path.exists(path):
923
+ metadata["success"] = True
924
+ metadata["http_status"] = 200 # Assume success if already exists
925
+ metadata["size_bytes"] = os.path.getsize(path)
926
+ return metadata
927
+
928
+ # Create simple request (no API query params)
929
+ request = Request(url)
930
+ request.add_header("Accept", "application/octet-stream")
931
+
932
+ # Add authentication header only for modern github.com/user-attachments URLs
933
+ # Legacy CDN URLs (user-images.githubusercontent.com) are public and don't need/accept auth
934
+ # Private CDN URLs (private-user-images) use JWT tokens embedded in the URL
935
+ if auth is not None and "github.com/user-attachments/" in url:
936
+ if not as_app:
937
+ if fine:
938
+ # Fine-grained token: plain token with "token " prefix
939
+ request.add_header("Authorization", "token " + auth)
940
+ else:
941
+ # Classic token: base64-encoded with "Basic " prefix
942
+ request.add_header("Authorization", "Basic ".encode("ascii") + auth)
943
+ else:
944
+ # App authentication
945
+ auth = auth.encode("ascii")
946
+ request.add_header("Authorization", "token ".encode("ascii") + auth)
947
+
948
+ # Reuse S3HTTPRedirectHandler from download_file()
949
+ opener = build_opener(S3HTTPRedirectHandler)
950
+
951
+ temp_path = path + ".temp"
952
+
953
+ try:
954
+ response = opener.open(request)
955
+ metadata["http_status"] = response.getcode()
956
+
957
+ # Extract Content-Type
958
+ content_type = response.headers.get("Content-Type", "").split(";")[0].strip()
959
+ if content_type:
960
+ metadata["content_type"] = content_type
961
+
962
+ # Extract original filename from Content-Disposition header
963
+ # Format: attachment; filename=example.mov or attachment;filename="example.mov"
964
+ content_disposition = response.headers.get("Content-Disposition", "")
965
+ if content_disposition:
966
+ # Match: filename=something or filename="something" or filename*=UTF-8''something
967
+ match = re.search(r'filename\*?=["\']?([^"\';\r\n]+)', content_disposition)
968
+ if match:
969
+ original_filename = match.group(1).strip()
970
+ # Handle RFC 5987 encoding: filename*=UTF-8''example.mov
971
+ if "UTF-8''" in original_filename:
972
+ original_filename = original_filename.split("UTF-8''")[1]
973
+ metadata["original_filename"] = original_filename
974
+
975
+ # Fallback: Extract filename from final URL after redirects
976
+ # This handles user-attachments/assets URLs which redirect to S3 with filename.ext
977
+ if not metadata["original_filename"]:
978
+ from urllib.parse import urlparse, unquote
979
+
980
+ final_url = response.geturl()
981
+ parsed = urlparse(final_url)
982
+ # Get filename from path (last component before query string)
983
+ path_parts = parsed.path.split("/")
984
+ if path_parts:
985
+ # URL might be encoded, decode it
986
+ filename_from_url = unquote(path_parts[-1])
987
+ # Only use if it has an extension
988
+ if "." in filename_from_url:
989
+ metadata["original_filename"] = filename_from_url
990
+
991
+ # Download file to temporary location
992
+ chunk_size = 16 * 1024
993
+ bytes_downloaded = 0
994
+ with open(temp_path, "wb") as f:
995
+ while True:
996
+ chunk = response.read(chunk_size)
997
+ if not chunk:
998
+ break
999
+ f.write(chunk)
1000
+ bytes_downloaded += len(chunk)
1001
+
1002
+ # Atomic rename to final location
1003
+ os.rename(temp_path, path)
1004
+
1005
+ metadata["size_bytes"] = bytes_downloaded
1006
+ metadata["success"] = True
1007
+
1008
+ except HTTPError as exc:
1009
+ metadata["http_status"] = exc.code
1010
+ metadata["error"] = str(exc.reason)
1011
+ logger.warning(
1012
+ "Skipping download of attachment {0} due to HTTPError: {1}".format(
1013
+ url, exc.reason
1014
+ )
1015
+ )
1016
+ except URLError as e:
1017
+ metadata["error"] = str(e.reason)
1018
+ logger.warning(
1019
+ "Skipping download of attachment {0} due to URLError: {1}".format(
1020
+ url, e.reason
1021
+ )
1022
+ )
1023
+ except socket.error as e:
1024
+ metadata["error"] = str(e.strerror) if hasattr(e, "strerror") else str(e)
1025
+ logger.warning(
1026
+ "Skipping download of attachment {0} due to socket error: {1}".format(
1027
+ url, e.strerror if hasattr(e, "strerror") else str(e)
1028
+ )
1029
+ )
1030
+ except Exception as e:
1031
+ metadata["error"] = str(e)
1032
+ logger.warning(
1033
+ "Skipping download of attachment {0} due to error: {1}".format(url, str(e))
1034
+ )
1035
+ # Clean up temp file if it was partially created
1036
+ if os.path.exists(temp_path):
1037
+ try:
1038
+ os.remove(temp_path)
1039
+ except Exception:
1040
+ pass
1041
+
1042
+ return metadata
1043
+
1044
+
1045
+ def extract_attachment_urls(item_data, issue_number=None, repository_full_name=None):
1046
+ """Extract GitHub-hosted attachment URLs from issue/PR body and comments.
1047
+
1048
+ What qualifies as an attachment?
1049
+ There is no "attachment" concept in the GitHub API - it's a user behavior pattern
1050
+ we've identified through analysis of real-world repositories. We define attachments as:
1051
+
1052
+ - User-uploaded files hosted on GitHub's CDN domains
1053
+ - Found outside of code blocks (not examples/documentation)
1054
+ - Matches known GitHub attachment URL patterns
1055
+
1056
+ This intentionally captures bare URLs pasted by users, not just markdown/HTML syntax.
1057
+ Some false positives (example URLs in documentation) may occur - these fail gracefully
1058
+ with HTTP 404 and are logged in the manifest.
1059
+
1060
+ Supported URL formats:
1061
+ - Modern: github.com/user-attachments/{assets,files}/*
1062
+ - Legacy: user-images.githubusercontent.com/* (including private-user-images)
1063
+ - Repo files: github.com/{owner}/{repo}/files/* (filtered to current repo)
1064
+ - Repo assets: github.com/{owner}/{repo}/assets/* (filtered to current repo)
1065
+
1066
+ Repository filtering (repo files/assets only):
1067
+ - Direct match: URL is for current repository → included
1068
+ - Redirect match: URL redirects to current repository → included (handles renames/transfers)
1069
+ - Different repo: URL is for different repository → excluded
1070
+
1071
+ Code block filtering:
1072
+ - Removes fenced code blocks (```) and inline code (`) before extraction
1073
+ - Prevents extracting URLs from code examples and documentation snippets
1074
+
1075
+ Args:
1076
+ item_data: Issue or PR data dict
1077
+ issue_number: Issue/PR number for logging
1078
+ repository_full_name: Full repository name (owner/repo) for filtering repo-scoped URLs
1079
+ """
1080
+ import re
1081
+
1082
+ urls = []
1083
+
1084
+ # Define all GitHub attachment patterns
1085
+ # Stop at markdown punctuation: whitespace, ), `, ", >, <
1086
+ # Trailing sentence punctuation (. ! ? , ; : ' ") is stripped in post-processing
1087
+ patterns = [
1088
+ r'https://github\.com/user-attachments/(?:assets|files)/[^\s\)`"<>]+', # Modern
1089
+ r'https://(?:private-)?user-images\.githubusercontent\.com/[^\s\)`"<>]+', # Legacy CDN
1090
+ ]
1091
+
1092
+ # Add repo-scoped patterns (will be filtered by repository later)
1093
+ # These patterns match ANY repo, then we filter to current repo with redirect checking
1094
+ repo_files_pattern = r'https://github\.com/[^/]+/[^/]+/files/\d+/[^\s\)`"<>]+'
1095
+ repo_assets_pattern = r'https://github\.com/[^/]+/[^/]+/assets/\d+/[^\s\)`"<>]+'
1096
+ patterns.append(repo_files_pattern)
1097
+ patterns.append(repo_assets_pattern)
1098
+
1099
+ def clean_url(url):
1100
+ """Remove trailing sentence and markdown punctuation that's not part of the URL."""
1101
+ return url.rstrip(".!?,;:'\")")
1102
+
1103
+ def remove_code_blocks(text):
1104
+ """Remove markdown code blocks (fenced and inline) from text.
1105
+
1106
+ This prevents extracting URLs from code examples like:
1107
+ - Fenced code blocks: ```code```
1108
+ - Inline code: `code`
1109
+ """
1110
+ # Remove fenced code blocks first (```...```)
1111
+ # DOTALL flag makes . match newlines
1112
+ text = re.sub(r"```.*?```", "", text, flags=re.DOTALL)
1113
+
1114
+ # Remove inline code (`...`)
1115
+ # Non-greedy match between backticks
1116
+ text = re.sub(r"`[^`]*`", "", text)
1117
+
1118
+ return text
1119
+
1120
+ def is_repo_scoped_url(url):
1121
+ """Check if URL is a repo-scoped attachment (files or assets)."""
1122
+ return bool(
1123
+ re.match(r"https://github\.com/[^/]+/[^/]+/(?:files|assets)/\d+/", url)
1124
+ )
1125
+
1126
+ def check_redirect_to_current_repo(url, current_repo):
1127
+ """Check if URL redirects to current repository.
1128
+
1129
+ Returns True if:
1130
+ - URL is already for current repo
1131
+ - URL redirects (301/302) to current repo (handles renames/transfers)
1132
+
1133
+ Returns False otherwise (URL is for a different repo).
1134
+ """
1135
+ # Extract owner/repo from URL
1136
+ match = re.match(r"https://github\.com/([^/]+)/([^/]+)/", url)
1137
+ if not match:
1138
+ return False
1139
+
1140
+ url_owner, url_repo = match.groups()
1141
+ url_repo_full = f"{url_owner}/{url_repo}"
1142
+
1143
+ # Direct match - no need to check redirect
1144
+ if url_repo_full.lower() == current_repo.lower():
1145
+ return True
1146
+
1147
+ # Different repo - check if it redirects to current repo
1148
+ # This handles repository transfers and renames
1149
+ try:
1150
+ import urllib.request
1151
+ import urllib.error
1152
+
1153
+ # Make HEAD request with redirect following disabled
1154
+ # We need to manually handle redirects to see the Location header
1155
+ request = urllib.request.Request(url, method="HEAD")
1156
+ request.add_header("User-Agent", "python-github-backup")
1157
+
1158
+ # Create opener that does NOT follow redirects
1159
+ class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
1160
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
1161
+ return None # Don't follow redirects
1162
+
1163
+ opener = urllib.request.build_opener(NoRedirectHandler)
1164
+
1165
+ try:
1166
+ _ = opener.open(request, timeout=10)
1167
+ # Got 200 - URL works as-is but for different repo
1168
+ return False
1169
+ except urllib.error.HTTPError as e:
1170
+ # Check if it's a redirect (301, 302, 307, 308)
1171
+ if e.code in (301, 302, 307, 308):
1172
+ location = e.headers.get("Location", "")
1173
+ # Check if redirect points to current repo
1174
+ if location:
1175
+ redirect_match = re.match(
1176
+ r"https://github\.com/([^/]+)/([^/]+)/", location
1177
+ )
1178
+ if redirect_match:
1179
+ redirect_owner, redirect_repo = redirect_match.groups()
1180
+ redirect_repo_full = f"{redirect_owner}/{redirect_repo}"
1181
+ return redirect_repo_full.lower() == current_repo.lower()
1182
+ return False
1183
+ except Exception:
1184
+ # On any error (timeout, network issue, etc.), be conservative
1185
+ # and exclude the URL to avoid downloading from wrong repos
1186
+ return False
1187
+
1188
+ # Extract from body
1189
+ body = item_data.get("body") or ""
1190
+ # Remove code blocks before searching for URLs
1191
+ body_cleaned = remove_code_blocks(body)
1192
+ for pattern in patterns:
1193
+ found_urls = re.findall(pattern, body_cleaned)
1194
+ urls.extend([clean_url(url) for url in found_urls])
1195
+
1196
+ # Extract from issue comments
1197
+ if "comment_data" in item_data:
1198
+ for comment in item_data["comment_data"]:
1199
+ comment_body = comment.get("body") or ""
1200
+ # Remove code blocks before searching for URLs
1201
+ comment_cleaned = remove_code_blocks(comment_body)
1202
+ for pattern in patterns:
1203
+ found_urls = re.findall(pattern, comment_cleaned)
1204
+ urls.extend([clean_url(url) for url in found_urls])
1205
+
1206
+ # Extract from PR regular comments
1207
+ if "comment_regular_data" in item_data:
1208
+ for comment in item_data["comment_regular_data"]:
1209
+ comment_body = comment.get("body") or ""
1210
+ # Remove code blocks before searching for URLs
1211
+ comment_cleaned = remove_code_blocks(comment_body)
1212
+ for pattern in patterns:
1213
+ found_urls = re.findall(pattern, comment_cleaned)
1214
+ urls.extend([clean_url(url) for url in found_urls])
1215
+
1216
+ regex_urls = list(set(urls)) # dedupe
1217
+
1218
+ # Filter repo-scoped URLs to current repository only
1219
+ # This handles repository transfers/renames via redirect checking
1220
+ if repository_full_name:
1221
+ filtered_urls = []
1222
+ for url in regex_urls:
1223
+ if is_repo_scoped_url(url):
1224
+ # Check if URL belongs to current repo (or redirects to it)
1225
+ if check_redirect_to_current_repo(url, repository_full_name):
1226
+ filtered_urls.append(url)
1227
+ # else: skip URLs from other repositories
1228
+ else:
1229
+ # Non-repo-scoped URLs (user-attachments, CDN) - always include
1230
+ filtered_urls.append(url)
1231
+ regex_urls = filtered_urls
1232
+
1233
+ return regex_urls
1234
+
1235
+
1236
+ def get_attachment_filename(url):
1237
+ """Get filename from attachment URL, handling all GitHub formats.
1238
+
1239
+ Formats:
1240
+ - github.com/user-attachments/assets/{uuid} → uuid (add extension later)
1241
+ - github.com/user-attachments/files/{id}/{filename} → filename
1242
+ - github.com/{owner}/{repo}/files/{id}/{filename} → filename
1243
+ - user-images.githubusercontent.com/{user}/{hash}.{ext} → hash.ext
1244
+ - private-user-images.githubusercontent.com/...?jwt=... → extract from path
1245
+ """
1246
+ from urllib.parse import urlparse
1247
+
1248
+ parsed = urlparse(url)
1249
+ path_parts = parsed.path.split("/")
1250
+
1251
+ # Modern: /user-attachments/files/{id}/{filename}
1252
+ if "user-attachments/files" in parsed.path:
1253
+ return path_parts[-1]
1254
+
1255
+ # Modern: /user-attachments/assets/{uuid}
1256
+ elif "user-attachments/assets" in parsed.path:
1257
+ return path_parts[-1] # extension added later via detect_and_add_extension
1258
+
1259
+ # Repo files: /{owner}/{repo}/files/{id}/{filename}
1260
+ elif "/files/" in parsed.path and len(path_parts) >= 2:
1261
+ return path_parts[-1]
1262
+
1263
+ # Legacy: user-images.githubusercontent.com/{user}/{hash-with-ext}
1264
+ elif "githubusercontent.com" in parsed.netloc:
1265
+ return path_parts[-1] # Already has extension usually
1266
+
1267
+ # Fallback: use last path component
1268
+ return path_parts[-1] if path_parts[-1] else "unknown_attachment"
1269
+
1270
+
1271
+ def resolve_filename_collision(filepath):
1272
+ """Resolve filename collisions using counter suffix pattern.
1273
+
1274
+ If filepath exists, returns a new filepath with counter suffix.
1275
+ Pattern: report.pdf → report_1.pdf → report_2.pdf
1276
+
1277
+ Also protects against manifest.json collisions by treating it as reserved.
1278
+
1279
+ Args:
1280
+ filepath: Full path to file that might exist
1281
+
1282
+ Returns:
1283
+ filepath that doesn't collide (may be same as input if no collision)
1284
+ """
1285
+ directory = os.path.dirname(filepath)
1286
+ filename = os.path.basename(filepath)
1287
+
1288
+ # Protect manifest.json - it's a reserved filename
1289
+ if filename == "manifest.json":
1290
+ name, ext = os.path.splitext(filename)
1291
+ counter = 1
1292
+ while True:
1293
+ new_filename = f"{name}_{counter}{ext}"
1294
+ new_filepath = os.path.join(directory, new_filename)
1295
+ if not os.path.exists(new_filepath):
1296
+ return new_filepath
1297
+ counter += 1
1298
+
1299
+ if not os.path.exists(filepath):
1300
+ return filepath
1301
+
1302
+ name, ext = os.path.splitext(filename)
1303
+
1304
+ counter = 1
1305
+ while True:
1306
+ new_filename = f"{name}_{counter}{ext}"
1307
+ new_filepath = os.path.join(directory, new_filename)
1308
+ if not os.path.exists(new_filepath):
1309
+ return new_filepath
1310
+ counter += 1
1311
+
1312
+
1313
+ def download_attachments(
1314
+ args, item_cwd, item_data, number, repository, item_type="issue"
1315
+ ):
1316
+ """Download user-attachments from issue/PR body and comments with manifest.
1317
+
1318
+ Args:
1319
+ args: Command line arguments
1320
+ item_cwd: Working directory (issue_cwd or pulls_cwd)
1321
+ item_data: Issue or PR data dict
1322
+ number: Issue or PR number
1323
+ repository: Repository dict
1324
+ item_type: "issue" or "pull" for logging/manifest
1325
+ """
1326
+ import json
1327
+ from datetime import datetime, timezone
1328
+
1329
+ item_type_display = "issue" if item_type == "issue" else "pull request"
1330
+
1331
+ urls = extract_attachment_urls(
1332
+ item_data, issue_number=number, repository_full_name=repository["full_name"]
1333
+ )
1334
+ if not urls:
1335
+ return
1336
+
1337
+ attachments_dir = os.path.join(item_cwd, "attachments", str(number))
1338
+ manifest_path = os.path.join(attachments_dir, "manifest.json")
1339
+
1340
+ # Load existing manifest if skip_existing is enabled
1341
+ existing_urls = set()
1342
+ existing_metadata = []
1343
+ if args.skip_existing and os.path.exists(manifest_path):
1344
+ try:
1345
+ with open(manifest_path, "r") as f:
1346
+ existing_manifest = json.load(f)
1347
+ all_metadata = existing_manifest.get("attachments", [])
1348
+ # Only skip URLs that were successfully downloaded OR failed with permanent errors
1349
+ # Retry transient failures (5xx, timeouts, network errors)
1350
+ for item in all_metadata:
1351
+ if item.get("success"):
1352
+ existing_urls.add(item["url"])
1353
+ else:
1354
+ # Check if this is a permanent failure (don't retry) or transient (retry)
1355
+ http_status = item.get("http_status")
1356
+ if http_status in [404, 410, 451]:
1357
+ # Permanent failures - don't retry
1358
+ existing_urls.add(item["url"])
1359
+ # Transient failures (5xx, auth errors, timeouts) will be retried
1360
+ existing_metadata = all_metadata
1361
+ except (json.JSONDecodeError, IOError):
1362
+ # If manifest is corrupted, re-download everything
1363
+ logger.warning(
1364
+ "Corrupted manifest for {0} #{1}, will re-download".format(
1365
+ item_type_display, number
1366
+ )
1367
+ )
1368
+ existing_urls = set()
1369
+ existing_metadata = []
1370
+
1371
+ # Filter to only new URLs
1372
+ new_urls = [url for url in urls if url not in existing_urls]
1373
+
1374
+ if not new_urls and existing_urls:
1375
+ logger.debug(
1376
+ "Skipping attachments for {0} #{1} (all {2} already downloaded)".format(
1377
+ item_type_display, number, len(urls)
1378
+ )
1379
+ )
1380
+ return
1381
+
1382
+ if new_urls:
1383
+ logger.info(
1384
+ "Downloading {0} new attachment(s) for {1} #{2}".format(
1385
+ len(new_urls), item_type_display, number
1386
+ )
1387
+ )
1388
+
1389
+ mkdir_p(item_cwd, attachments_dir)
1390
+
1391
+ # Collect metadata for manifest (start with existing)
1392
+ attachment_metadata_list = existing_metadata[:]
1393
+
1394
+ for url in new_urls:
1395
+ filename = get_attachment_filename(url)
1396
+ filepath = os.path.join(attachments_dir, filename)
1397
+
1398
+ # Check for collision BEFORE downloading
1399
+ filepath = resolve_filename_collision(filepath)
1400
+
1401
+ # Download and get metadata
1402
+ metadata = download_attachment_file(
1403
+ url,
1404
+ filepath,
1405
+ get_auth(args, encode=not args.as_app),
1406
+ as_app=args.as_app,
1407
+ fine=args.token_fine is not None,
1408
+ )
1409
+
1410
+ # If download succeeded but we got an extension from Content-Disposition,
1411
+ # we may need to rename the file to add the extension
1412
+ if metadata["success"] and metadata.get("original_filename"):
1413
+ original_ext = os.path.splitext(metadata["original_filename"])[1]
1414
+ current_ext = os.path.splitext(filepath)[1]
1415
+
1416
+ # Add extension if not present
1417
+ if original_ext and current_ext != original_ext:
1418
+ final_filepath = filepath + original_ext
1419
+ # Check for collision again with new extension
1420
+ final_filepath = resolve_filename_collision(final_filepath)
1421
+ logger.debug(
1422
+ "Adding extension {0} to {1}".format(original_ext, filepath)
1423
+ )
1424
+
1425
+ # Rename to add extension (already atomic from download)
1426
+ try:
1427
+ os.rename(filepath, final_filepath)
1428
+ metadata["saved_as"] = os.path.basename(final_filepath)
1429
+ except Exception as e:
1430
+ logger.warning(
1431
+ "Could not add extension to {0}: {1}".format(filepath, str(e))
1432
+ )
1433
+ metadata["saved_as"] = os.path.basename(filepath)
1434
+ else:
1435
+ metadata["saved_as"] = os.path.basename(filepath)
1436
+ elif metadata["success"]:
1437
+ metadata["saved_as"] = os.path.basename(filepath)
1438
+ else:
1439
+ metadata["saved_as"] = None
1440
+
1441
+ attachment_metadata_list.append(metadata)
1442
+
1443
+ # Write manifest
1444
+ if attachment_metadata_list:
1445
+ manifest = {
1446
+ "issue_number": number,
1447
+ "issue_type": item_type,
1448
+ "repository": f"{args.user}/{args.repository}"
1449
+ if hasattr(args, "repository") and args.repository
1450
+ else args.user,
1451
+ "manifest_updated_at": datetime.now(timezone.utc).isoformat(),
1452
+ "attachments": attachment_metadata_list,
1453
+ }
1454
+
1455
+ manifest_path = os.path.join(attachments_dir, "manifest.json")
1456
+ with open(manifest_path + ".temp", "w") as f:
1457
+ json.dump(manifest, f, indent=2)
1458
+ os.rename(manifest_path + ".temp", manifest_path) # Atomic write
1459
+ logger.debug(
1460
+ "Wrote manifest for {0} #{1}: {2} attachments".format(
1461
+ item_type_display, number, len(attachment_metadata_list)
1462
+ )
1463
+ )
1464
+
1465
+
853
1466
  def get_authenticated_user(args):
854
1467
  template = "https://{0}/user".format(get_github_api_host(args))
855
1468
  data = retrieve_data(args, template, single_request=True)
@@ -885,9 +1498,13 @@ def retrieve_repositories(args, authenticated_user):
885
1498
  )
886
1499
 
887
1500
  if args.repository:
1501
+ if "/" in args.repository:
1502
+ repo_path = args.repository
1503
+ else:
1504
+ repo_path = "{0}/{1}".format(args.user, args.repository)
888
1505
  single_request = True
889
- template = "https://{0}/repos/{1}/{2}".format(
890
- get_github_api_host(args), args.user, args.repository
1506
+ template = "https://{0}/repos/{1}".format(
1507
+ get_github_api_host(args), repo_path
891
1508
  )
892
1509
 
893
1510
  repos = retrieve_data(args, template, single_request=single_request)
@@ -928,6 +1545,8 @@ def retrieve_repositories(args, authenticated_user):
928
1545
 
929
1546
 
930
1547
  def filter_repositories(args, unfiltered_repositories):
1548
+ if args.repository:
1549
+ return unfiltered_repositories
931
1550
  logger.info("Filtering repositories")
932
1551
 
933
1552
  repositories = []
@@ -1134,6 +1753,10 @@ def backup_issues(args, repo_cwd, repository, repos_template):
1134
1753
  if args.include_issue_events or args.include_everything:
1135
1754
  template = events_template.format(number)
1136
1755
  issues[number]["event_data"] = retrieve_data(args, template)
1756
+ if args.include_attachments:
1757
+ download_attachments(
1758
+ args, issue_cwd, issues[number], number, repository, item_type="issue"
1759
+ )
1137
1760
 
1138
1761
  with codecs.open(issue_file + ".temp", "w", encoding="utf-8") as f:
1139
1762
  json_dump(issue, f)
@@ -1205,6 +1828,10 @@ def backup_pulls(args, repo_cwd, repository, repos_template):
1205
1828
  if args.include_pull_commits or args.include_everything:
1206
1829
  template = commits_template.format(number)
1207
1830
  pulls[number]["commit_data"] = retrieve_data(args, template)
1831
+ if args.include_attachments:
1832
+ download_attachments(
1833
+ args, pulls_cwd, pulls[number], number, repository, item_type="pull"
1834
+ )
1208
1835
 
1209
1836
  with codecs.open(pull_file + ".temp", "w", encoding="utf-8") as f:
1210
1837
  json_dump(pull, f)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.50.2
3
+ Version: 0.51.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
@@ -9,11 +9,12 @@ License: MIT
9
9
  Classifier: Development Status :: 5 - Production/Stable
10
10
  Classifier: Topic :: System :: Archiving :: Backup
11
11
  Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3.8
13
- Classifier: Programming Language :: Python :: 3.9
14
12
  Classifier: Programming Language :: Python :: 3.10
15
13
  Classifier: Programming Language :: Python :: 3.11
16
14
  Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Requires-Python: >=3.10
17
18
  Description-Content-Type: text/x-rst
18
19
  License-File: LICENSE.txt
19
20
  Dynamic: author
@@ -24,6 +25,7 @@ Dynamic: description-content-type
24
25
  Dynamic: home-page
25
26
  Dynamic: license
26
27
  Dynamic: license-file
28
+ Dynamic: requires-python
27
29
  Dynamic: summary
28
30
 
29
31
  =============
@@ -37,8 +39,8 @@ The package can be used to backup an *entire* `Github <https://github.com/>`_ or
37
39
  Requirements
38
40
  ============
39
41
 
42
+ - Python 3.10 or higher
40
43
  - GIT 1.9+
41
- - Python
42
44
 
43
45
  Installation
44
46
  ============
@@ -78,7 +80,7 @@ CLI Help output::
78
80
  [--keychain-name OSX_KEYCHAIN_ITEM_NAME]
79
81
  [--keychain-account OSX_KEYCHAIN_ITEM_ACCOUNT]
80
82
  [--releases] [--latest-releases NUMBER_OF_LATEST_RELEASES]
81
- [--skip-prerelease] [--assets]
83
+ [--skip-prerelease] [--assets] [--attachments]
82
84
  [--exclude [REPOSITORY [REPOSITORY ...]]
83
85
  [--throttle-limit THROTTLE_LIMIT] [--throttle-pause THROTTLE_PAUSE]
84
86
  USER
@@ -161,6 +163,9 @@ CLI Help output::
161
163
  --skip-prerelease skip prerelease and draft versions; only applies if including releases
162
164
  --assets include assets alongside release information; only
163
165
  applies if including releases
166
+ --attachments download user-attachments from issues and pull requests
167
+ to issues/attachments/{issue_number}/ and
168
+ pulls/attachments/{pull_number}/ directories
164
169
  --exclude [REPOSITORY [REPOSITORY ...]]
165
170
  names of repositories to exclude from backup.
166
171
  --throttle-limit THROTTLE_LIMIT
@@ -241,6 +246,29 @@ When you use the ``--lfs`` option, you will need to make sure you have Git LFS i
241
246
  Instructions on how to do this can be found on https://git-lfs.github.com.
242
247
 
243
248
 
249
+ About Attachments
250
+ -----------------
251
+
252
+ When you use the ``--attachments`` option with ``--issues`` or ``--pulls``, the tool will download user-uploaded attachments (images, videos, documents, etc.) from issue and pull request descriptions and comments. In some circumstances attachments contain valuable data related to the topic, and without their backup important information or context might be lost inadvertently.
253
+
254
+ Attachments are saved to ``issues/attachments/{issue_number}/`` and ``pulls/attachments/{pull_number}/`` directories, where ``{issue_number}`` is the GitHub issue number (e.g., issue #123 saves to ``issues/attachments/123/``). Each attachment directory contains:
255
+
256
+ - The downloaded attachment files (named by their GitHub identifier with appropriate file extensions)
257
+ - If multiple attachments have the same filename, conflicts are resolved with numeric suffixes (e.g., ``report.pdf``, ``report_1.pdf``, ``report_2.pdf``)
258
+ - A ``manifest.json`` file documenting all downloads, including URLs, file metadata, and download status
259
+
260
+ The tool automatically extracts file extensions from HTTP headers to ensure files can be more easily opened by your operating system.
261
+
262
+ **Supported URL formats:**
263
+
264
+ - Modern: ``github.com/user-attachments/{assets,files}/*``
265
+ - Legacy: ``user-images.githubusercontent.com/*`` and ``private-user-images.githubusercontent.com/*``
266
+ - Repo files: ``github.com/{owner}/{repo}/files/*`` (filtered to current repository)
267
+ - Repo assets: ``github.com/{owner}/{repo}/assets/*`` (filtered to current repository)
268
+
269
+ **Repository filtering** for repo files/assets handles renamed and transferred repositories gracefully. URLs are included if they either match the current repository name directly, or redirect to it (e.g., ``willmcgugan/rich`` redirects to ``Textualize/rich`` after transfer).
270
+
271
+
244
272
  Run in Docker container
245
273
  -----------------------
246
274
 
@@ -331,7 +359,7 @@ Quietly and incrementally backup useful Github user data (public and private rep
331
359
  export FINE_ACCESS_TOKEN=SOME-GITHUB-TOKEN
332
360
  GH_USER=YOUR-GITHUB-USER
333
361
 
334
- github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --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
362
+ github-backup -f $FINE_ACCESS_TOKEN --prefer-ssh -o ~/github-backup/ -l error -P -i --all-starred --starred --watched --followers --following --issues --issue-comments --issue-events --pulls --pull-comments --pull-commits --labels --milestones --repositories --wikis --releases --assets --attachments --pull-details --gists --starred-gists $GH_USER
335
363
 
336
364
  Debug an error/block or incomplete backup into a temporary directory. Omit "incremental" to fill a previous incomplete backup. ::
337
365
 
@@ -0,0 +1,9 @@
1
+ github_backup/__init__.py,sha256=5j_riw1v3MMRXNOOd6h7WlISOYlrHrS61_8RReiM4yA,23
2
+ github_backup/github_backup.py,sha256=RuqAJo0U7q3BdSp--vxvhZJWPzXrhaj1aRjwvBqRCRc,75127
3
+ github_backup-0.51.0.data/scripts/github-backup,sha256=I79TNA67phytRmI9nm_LlD9h2zauXFOsOn9tYyAy9pU,1546
4
+ github_backup-0.51.0.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
5
+ github_backup-0.51.0.dist-info/METADATA,sha256=hs33ckMCaiNoIPcTp3C7dX07tBGR5YdSZ79NNVwiWZg,21544
6
+ github_backup-0.51.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
+ github_backup-0.51.0.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
8
+ github_backup-0.51.0.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
9
+ github_backup-0.51.0.dist-info/RECORD,,
@@ -1,9 +0,0 @@
1
- github_backup/__init__.py,sha256=wGAaVepT9NGUHa2UImMJqVwxYz7S7aIYbR1KZt9OLHo,23
2
- github_backup/github_backup.py,sha256=zdd7nk2y2lGXcdLRvfChWSAf1qWVcLH3qZexCnPwpeI,49846
3
- github_backup-0.50.2.data/scripts/github-backup,sha256=I79TNA67phytRmI9nm_LlD9h2zauXFOsOn9tYyAy9pU,1546
4
- github_backup-0.50.2.dist-info/licenses/LICENSE.txt,sha256=ML-pLyn1qtAKo_DHMcQ8-p7k3f8cp-IR-mf5B49R2rk,1085
5
- github_backup-0.50.2.dist-info/METADATA,sha256=nm98RKSZu_yzKVpgKYkkdeveH8hcDwg1UHwHrm2a8lg,19402
6
- github_backup-0.50.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
7
- github_backup-0.50.2.dist-info/top_level.txt,sha256=Za6fKRAlxZ0b3ZBH_osPqklLe14LetdNAkvAQANT2AA,14
8
- github_backup-0.50.2.dist-info/zip-safe,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
9
- github_backup-0.50.2.dist-info/RECORD,,