github-backup 0.51.0__tar.gz → 0.51.2__tar.gz

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.
Files changed (21) hide show
  1. {github_backup-0.51.0 → github_backup-0.51.2}/CHANGES.rst +117 -1
  2. {github_backup-0.51.0/github_backup.egg-info → github_backup-0.51.2}/PKG-INFO +1 -1
  3. github_backup-0.51.2/github_backup/__init__.py +1 -0
  4. {github_backup-0.51.0 → github_backup-0.51.2}/github_backup/github_backup.py +28 -26
  5. {github_backup-0.51.0 → github_backup-0.51.2/github_backup.egg-info}/PKG-INFO +1 -1
  6. {github_backup-0.51.0 → github_backup-0.51.2}/github_backup.egg-info/SOURCES.txt +2 -1
  7. {github_backup-0.51.0 → github_backup-0.51.2}/release-requirements.txt +4 -3
  8. github_backup-0.51.2/requirements.txt +0 -0
  9. github_backup-0.51.2/tests/test_attachments.py +353 -0
  10. github_backup-0.51.0/github_backup/__init__.py +0 -1
  11. github_backup-0.51.0/requirements.txt +0 -1
  12. {github_backup-0.51.0 → github_backup-0.51.2}/LICENSE.txt +0 -0
  13. {github_backup-0.51.0 → github_backup-0.51.2}/MANIFEST.in +0 -0
  14. {github_backup-0.51.0 → github_backup-0.51.2}/README +0 -0
  15. {github_backup-0.51.0 → github_backup-0.51.2}/README.rst +0 -0
  16. {github_backup-0.51.0 → github_backup-0.51.2}/bin/github-backup +0 -0
  17. {github_backup-0.51.0 → github_backup-0.51.2}/github_backup.egg-info/dependency_links.txt +0 -0
  18. {github_backup-0.51.0 → github_backup-0.51.2}/github_backup.egg-info/top_level.txt +0 -0
  19. {github_backup-0.51.0 → github_backup-0.51.2}/github_backup.egg-info/zip-safe +0 -0
  20. {github_backup-0.51.0 → github_backup-0.51.2}/setup.cfg +0 -0
  21. {github_backup-0.51.0 → github_backup-0.51.2}/setup.py +0 -0
@@ -1,10 +1,126 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.51.0 (2025-11-06)
4
+ 0.51.2 (2025-11-16)
5
5
  -------------------
6
6
  ------------------------
7
7
 
8
+ Fix
9
+ ~~~
10
+ - Improve CA certificate detection with fallback chain. [Rodos]
11
+
12
+ The previous implementation incorrectly assumed empty get_ca_certs()
13
+ meant broken SSL, causing false failures in GitHub Codespaces and other
14
+ directory-based cert systems where certificates exist but aren't pre-loaded.
15
+ It would then attempt to import certifi as a workaround, but certifi wasn't
16
+ listed in requirements.txt, causing the fallback to fail with ImportError
17
+ even though the system certificates would have worked fine.
18
+
19
+ This commit replaces the naive check with a layered fallback approach that
20
+ checks multiple certificate sources. First it checks for pre-loaded system
21
+ certs (file-based systems). Then it verifies system cert paths exist
22
+ (directory-based systems like Ubuntu/Debian/Codespaces). Finally it attempts
23
+ to use certifi as an optional fallback only if needed.
24
+
25
+ This approach eliminates hard dependencies (certifi is now optional), works
26
+ in GitHub Codespaces without any setup, and fails gracefully with clear hints
27
+ for resolution when SSL is actually broken rather than failing with
28
+ ModuleNotFoundError.
29
+
30
+ Fixes #444
31
+
32
+
33
+ 0.51.1 (2025-11-16)
34
+ -------------------
35
+
36
+ Fix
37
+ ~~~
38
+ - Prevent duplicate attachment downloads. [Rodos]
39
+
40
+ Fixes bug where attachments were downloaded multiple times with
41
+ incremented filenames (file.mov, file_1.mov, file_2.mov) when
42
+ running backups without --skip-existing flag.
43
+
44
+ I should not have used the --skip-existing flag for attachments,
45
+ it did not do what I thought it did.
46
+
47
+ The correct approach is to always use the manifest to guide what
48
+ has already been downloaded and what now needs to be done.
49
+
50
+ Other
51
+ ~~~~~
52
+ - Chore(deps): bump certifi in the python-packages group.
53
+ [dependabot[bot]]
54
+
55
+ Bumps the python-packages group with 1 update: [certifi](https://github.com/certifi/python-certifi).
56
+
57
+
58
+ Updates `certifi` from 2025.10.5 to 2025.11.12
59
+ - [Commits](https://github.com/certifi/python-certifi/compare/2025.10.05...2025.11.12)
60
+
61
+ ---
62
+ updated-dependencies:
63
+ - dependency-name: certifi
64
+ dependency-version: 2025.11.12
65
+ dependency-type: direct:production
66
+ update-type: version-update:semver-minor
67
+ dependency-group: python-packages
68
+ ...
69
+ - Test: Add pytest infrastructure and attachment tests. [Rodos]
70
+
71
+ In making my last fix to attachments, I found it challenging not
72
+ having tests to ensure there was no regression.
73
+
74
+ Added pytest with minimal setup and isolated configuration. Created
75
+ a separate test workflow to keep tests isolated from linting.
76
+
77
+ Tests cover the key elements of the attachment logic:
78
+ - URL extraction from issue bodies
79
+ - Filename extraction from different URL types
80
+ - Filename collision resolution
81
+ - Manifest duplicate prevention
82
+ - Chore(deps): bump black in the python-packages group.
83
+ [dependabot[bot]]
84
+
85
+ Bumps the python-packages group with 1 update: [black](https://github.com/psf/black).
86
+
87
+
88
+ Updates `black` from 25.9.0 to 25.11.0
89
+ - [Release notes](https://github.com/psf/black/releases)
90
+ - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
91
+ - [Commits](https://github.com/psf/black/compare/25.9.0...25.11.0)
92
+
93
+ ---
94
+ updated-dependencies:
95
+ - dependency-name: black
96
+ dependency-version: 25.11.0
97
+ dependency-type: direct:production
98
+ update-type: version-update:semver-minor
99
+ dependency-group: python-packages
100
+ ...
101
+ - Chore(deps): bump docutils in the python-packages group.
102
+ [dependabot[bot]]
103
+
104
+ Bumps the python-packages group with 1 update: [docutils](https://github.com/rtfd/recommonmark).
105
+
106
+
107
+ Updates `docutils` from 0.22.2 to 0.22.3
108
+ - [Changelog](https://github.com/readthedocs/recommonmark/blob/master/CHANGELOG.md)
109
+ - [Commits](https://github.com/rtfd/recommonmark/commits)
110
+
111
+ ---
112
+ updated-dependencies:
113
+ - dependency-name: docutils
114
+ dependency-version: 0.22.3
115
+ dependency-type: direct:production
116
+ update-type: version-update:semver-patch
117
+ dependency-group: python-packages
118
+ ...
119
+
120
+
121
+ 0.51.0 (2025-11-06)
122
+ -------------------
123
+
8
124
  Fix
9
125
  ~~~
10
126
  - Remove Python 3.8 and 3.9 from CI matrix. [Rodos]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.51.0
3
+ Version: 0.51.2
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
@@ -0,0 +1 @@
1
+ __version__ = "0.51.2"
@@ -37,22 +37,33 @@ FNULL = open(os.devnull, "w")
37
37
  FILE_URI_PREFIX = "file://"
38
38
  logger = logging.getLogger(__name__)
39
39
 
40
+ # Setup SSL context with fallback chain
40
41
  https_ctx = ssl.create_default_context()
41
- if not https_ctx.get_ca_certs():
42
- import warnings
43
-
44
- warnings.warn(
45
- "\n\nYOUR DEFAULT CA CERTS ARE EMPTY.\n"
46
- + "PLEASE POPULATE ANY OF:"
47
- + "".join(
48
- ["\n - " + x for x in ssl.get_default_verify_paths() if type(x) is str]
49
- )
50
- + "\n",
51
- stacklevel=2,
52
- )
53
- import certifi
54
-
55
- https_ctx = ssl.create_default_context(cafile=certifi.where())
42
+ if https_ctx.get_ca_certs():
43
+ # Layer 1: Certificates pre-loaded from system (file-based)
44
+ pass
45
+ else:
46
+ paths = ssl.get_default_verify_paths()
47
+ if (paths.cafile and os.path.exists(paths.cafile)) or (
48
+ paths.capath and os.path.exists(paths.capath)
49
+ ):
50
+ # Layer 2: Cert paths exist, will be lazy-loaded on first use (directory-based)
51
+ pass
52
+ else:
53
+ # Layer 3: Try certifi package as optional fallback
54
+ try:
55
+ import certifi
56
+
57
+ https_ctx = ssl.create_default_context(cafile=certifi.where())
58
+ except ImportError:
59
+ # All layers failed - no certificates available anywhere
60
+ sys.exit(
61
+ "\nERROR: No CA certificates found. Cannot connect to GitHub over SSL.\n\n"
62
+ "Solutions you can explore:\n"
63
+ " 1. pip install certifi\n"
64
+ " 2. Alpine: apk add ca-certificates\n"
65
+ " 3. Debian/Ubuntu: apt-get install ca-certificates\n\n"
66
+ )
56
67
 
57
68
 
58
69
  def logging_subprocess(
@@ -919,12 +930,6 @@ def download_attachment_file(url, path, auth, as_app=False, fine=False):
919
930
  "error": None,
920
931
  }
921
932
 
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
933
  # Create simple request (no API query params)
929
934
  request = Request(url)
930
935
  request.add_header("Accept", "application/octet-stream")
@@ -1337,10 +1342,10 @@ def download_attachments(
1337
1342
  attachments_dir = os.path.join(item_cwd, "attachments", str(number))
1338
1343
  manifest_path = os.path.join(attachments_dir, "manifest.json")
1339
1344
 
1340
- # Load existing manifest if skip_existing is enabled
1345
+ # Load existing manifest to prevent duplicate downloads
1341
1346
  existing_urls = set()
1342
1347
  existing_metadata = []
1343
- if args.skip_existing and os.path.exists(manifest_path):
1348
+ if os.path.exists(manifest_path):
1344
1349
  try:
1345
1350
  with open(manifest_path, "r") as f:
1346
1351
  existing_manifest = json.load(f)
@@ -1395,9 +1400,6 @@ def download_attachments(
1395
1400
  filename = get_attachment_filename(url)
1396
1401
  filepath = os.path.join(attachments_dir, filename)
1397
1402
 
1398
- # Check for collision BEFORE downloading
1399
- filepath = resolve_filename_collision(filepath)
1400
-
1401
1403
  # Download and get metadata
1402
1404
  metadata = download_attachment_file(
1403
1405
  url,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-backup
3
- Version: 0.51.0
3
+ Version: 0.51.2
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
@@ -13,4 +13,5 @@ github_backup.egg-info/PKG-INFO
13
13
  github_backup.egg-info/SOURCES.txt
14
14
  github_backup.egg-info/dependency_links.txt
15
15
  github_backup.egg-info/top_level.txt
16
- github_backup.egg-info/zip-safe
16
+ github_backup.egg-info/zip-safe
17
+ tests/test_attachments.py
@@ -1,13 +1,14 @@
1
1
  autopep8==2.3.2
2
- black==25.9.0
2
+ black==25.11.0
3
3
  bleach==6.3.0
4
- certifi==2025.10.5
4
+ certifi==2025.11.12
5
5
  charset-normalizer==3.4.4
6
6
  click==8.3.0
7
7
  colorama==0.4.6
8
- docutils==0.22.2
8
+ docutils==0.22.3
9
9
  flake8==7.3.0
10
10
  gitchangelog==3.0.4
11
+ pytest==8.3.3
11
12
  idna==3.11
12
13
  importlib-metadata==8.7.0
13
14
  jaraco.classes==3.4.0
File without changes
@@ -0,0 +1,353 @@
1
+ """Behavioral tests for attachment functionality."""
2
+
3
+ import json
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from unittest.mock import Mock
8
+
9
+ import pytest
10
+
11
+ from github_backup import github_backup
12
+
13
+
14
+ @pytest.fixture
15
+ def attachment_test_setup(tmp_path):
16
+ """Fixture providing setup and helper for attachment download tests."""
17
+ from unittest.mock import patch
18
+
19
+ issue_cwd = tmp_path / "issues"
20
+ issue_cwd.mkdir()
21
+
22
+ # Mock args
23
+ args = Mock()
24
+ args.as_app = False
25
+ args.token_fine = None
26
+ args.token_classic = None
27
+ args.username = None
28
+ args.password = None
29
+ args.osx_keychain_item_name = None
30
+ args.osx_keychain_item_account = None
31
+ args.user = "testuser"
32
+ args.repository = "testrepo"
33
+
34
+ repository = {"full_name": "testuser/testrepo"}
35
+
36
+ def call_download(issue_data, issue_number=123):
37
+ """Call download_attachments with mocked HTTP downloads.
38
+
39
+ Returns list of URLs that were actually downloaded.
40
+ """
41
+ downloaded_urls = []
42
+
43
+ def mock_download(url, path, auth, as_app, fine):
44
+ downloaded_urls.append(url)
45
+ return {
46
+ "success": True,
47
+ "saved_as": os.path.basename(path),
48
+ "url": url,
49
+ }
50
+
51
+ with patch(
52
+ "github_backup.github_backup.download_attachment_file",
53
+ side_effect=mock_download,
54
+ ):
55
+ github_backup.download_attachments(
56
+ args, str(issue_cwd), issue_data, issue_number, repository
57
+ )
58
+
59
+ return downloaded_urls
60
+
61
+ return {
62
+ "issue_cwd": str(issue_cwd),
63
+ "args": args,
64
+ "repository": repository,
65
+ "call_download": call_download,
66
+ }
67
+
68
+
69
+ class TestURLExtraction:
70
+ """Test URL extraction with realistic issue content."""
71
+
72
+ def test_mixed_urls(self):
73
+ issue_data = {
74
+ "body": """
75
+ ## Bug Report
76
+
77
+ When uploading files, I see this error. Here's a screenshot:
78
+ https://github.com/user-attachments/assets/abc123def456
79
+
80
+ The logs show: https://github.com/user-attachments/files/789/error-log.txt
81
+
82
+ This is similar to https://github.com/someorg/somerepo/issues/42 but different.
83
+
84
+ You can also see the video at https://user-images.githubusercontent.com/12345/video-demo.mov
85
+
86
+ Here's how to reproduce:
87
+ ```bash
88
+ # Don't extract this example URL:
89
+ curl https://github.com/user-attachments/assets/example999
90
+ ```
91
+
92
+ More info at https://docs.example.com/guide
93
+
94
+ Also see this inline code `https://github.com/user-attachments/files/111/inline.pdf` should not extract.
95
+
96
+ Final attachment: https://github.com/user-attachments/files/222/report.pdf.
97
+ """,
98
+ "comment_data": [
99
+ {
100
+ "body": "Here's another attachment: https://private-user-images.githubusercontent.com/98765/secret.png?jwt=token123"
101
+ },
102
+ {
103
+ "body": """
104
+ Example code:
105
+ ```python
106
+ url = "https://github.com/user-attachments/assets/code-example"
107
+ ```
108
+ But this is real: https://github.com/user-attachments/files/333/actual.zip
109
+ """
110
+ },
111
+ ],
112
+ }
113
+
114
+ # Extract URLs
115
+ urls = github_backup.extract_attachment_urls(issue_data)
116
+
117
+ expected_urls = [
118
+ "https://github.com/user-attachments/assets/abc123def456",
119
+ "https://github.com/user-attachments/files/789/error-log.txt",
120
+ "https://user-images.githubusercontent.com/12345/video-demo.mov",
121
+ "https://github.com/user-attachments/files/222/report.pdf",
122
+ "https://private-user-images.githubusercontent.com/98765/secret.png?jwt=token123",
123
+ "https://github.com/user-attachments/files/333/actual.zip",
124
+ ]
125
+
126
+ assert set(urls) == set(expected_urls)
127
+
128
+ def test_trailing_punctuation_stripped(self):
129
+ """URLs with trailing punctuation should have punctuation stripped."""
130
+ issue_data = {
131
+ "body": """
132
+ See this file: https://github.com/user-attachments/files/1/doc.pdf.
133
+ And this one (https://github.com/user-attachments/files/2/image.png).
134
+ Check it out! https://github.com/user-attachments/files/3/data.csv!
135
+ """
136
+ }
137
+
138
+ urls = github_backup.extract_attachment_urls(issue_data)
139
+
140
+ expected = [
141
+ "https://github.com/user-attachments/files/1/doc.pdf",
142
+ "https://github.com/user-attachments/files/2/image.png",
143
+ "https://github.com/user-attachments/files/3/data.csv",
144
+ ]
145
+ assert set(urls) == set(expected)
146
+
147
+ def test_deduplication_across_body_and_comments(self):
148
+ """Same URL in body and comments should only appear once."""
149
+ duplicate_url = "https://github.com/user-attachments/assets/abc123"
150
+
151
+ issue_data = {
152
+ "body": f"First mention: {duplicate_url}",
153
+ "comment_data": [
154
+ {"body": f"Second mention: {duplicate_url}"},
155
+ {"body": f"Third mention: {duplicate_url}"},
156
+ ],
157
+ }
158
+
159
+ urls = github_backup.extract_attachment_urls(issue_data)
160
+
161
+ assert set(urls) == {duplicate_url}
162
+
163
+
164
+ class TestFilenameExtraction:
165
+ """Test filename extraction from different URL types."""
166
+
167
+ def test_modern_assets_url(self):
168
+ """Modern assets URL returns UUID."""
169
+ url = "https://github.com/user-attachments/assets/abc123def456"
170
+ filename = github_backup.get_attachment_filename(url)
171
+ assert filename == "abc123def456"
172
+
173
+ def test_modern_files_url(self):
174
+ """Modern files URL returns filename."""
175
+ url = "https://github.com/user-attachments/files/12345/report.pdf"
176
+ filename = github_backup.get_attachment_filename(url)
177
+ assert filename == "report.pdf"
178
+
179
+ def test_legacy_cdn_url(self):
180
+ """Legacy CDN URL returns filename with extension."""
181
+ url = "https://user-images.githubusercontent.com/123456/abc-def.png"
182
+ filename = github_backup.get_attachment_filename(url)
183
+ assert filename == "abc-def.png"
184
+
185
+ def test_private_cdn_url(self):
186
+ """Private CDN URL returns filename."""
187
+ url = "https://private-user-images.githubusercontent.com/98765/secret.png?jwt=token123"
188
+ filename = github_backup.get_attachment_filename(url)
189
+ assert filename == "secret.png"
190
+
191
+ def test_repo_files_url(self):
192
+ """Repo-scoped files URL returns filename."""
193
+ url = "https://github.com/owner/repo/files/789/document.txt"
194
+ filename = github_backup.get_attachment_filename(url)
195
+ assert filename == "document.txt"
196
+
197
+
198
+ class TestFilenameCollision:
199
+ """Test filename collision resolution."""
200
+
201
+ def test_collision_behavior(self):
202
+ """Test filename collision resolution with real files."""
203
+ with tempfile.TemporaryDirectory() as tmpdir:
204
+ # No collision - file doesn't exist
205
+ result = github_backup.resolve_filename_collision(
206
+ os.path.join(tmpdir, "report.pdf")
207
+ )
208
+ assert result == os.path.join(tmpdir, "report.pdf")
209
+
210
+ # Create the file, now collision exists
211
+ Path(os.path.join(tmpdir, "report.pdf")).touch()
212
+ result = github_backup.resolve_filename_collision(
213
+ os.path.join(tmpdir, "report.pdf")
214
+ )
215
+ assert result == os.path.join(tmpdir, "report_1.pdf")
216
+
217
+ # Create report_1.pdf too
218
+ Path(os.path.join(tmpdir, "report_1.pdf")).touch()
219
+ result = github_backup.resolve_filename_collision(
220
+ os.path.join(tmpdir, "report.pdf")
221
+ )
222
+ assert result == os.path.join(tmpdir, "report_2.pdf")
223
+
224
+ def test_manifest_reserved(self):
225
+ """manifest.json is always treated as reserved."""
226
+ with tempfile.TemporaryDirectory() as tmpdir:
227
+ # Even if manifest.json doesn't exist, should get manifest_1.json
228
+ result = github_backup.resolve_filename_collision(
229
+ os.path.join(tmpdir, "manifest.json")
230
+ )
231
+ assert result == os.path.join(tmpdir, "manifest_1.json")
232
+
233
+
234
+ class TestManifestDuplicatePrevention:
235
+ """Test that manifest prevents duplicate downloads (the bug fix)."""
236
+
237
+ def test_manifest_filters_existing_urls(self, attachment_test_setup):
238
+ """URLs in manifest are not re-downloaded."""
239
+ setup = attachment_test_setup
240
+
241
+ # Create manifest with existing URLs
242
+ attachments_dir = os.path.join(setup["issue_cwd"], "attachments", "123")
243
+ os.makedirs(attachments_dir)
244
+ manifest_path = os.path.join(attachments_dir, "manifest.json")
245
+
246
+ manifest = {
247
+ "attachments": [
248
+ {
249
+ "url": "https://github.com/user-attachments/assets/old1",
250
+ "success": True,
251
+ "saved_as": "old1.pdf",
252
+ },
253
+ {
254
+ "url": "https://github.com/user-attachments/assets/old2",
255
+ "success": True,
256
+ "saved_as": "old2.pdf",
257
+ },
258
+ ]
259
+ }
260
+ with open(manifest_path, "w") as f:
261
+ json.dump(manifest, f)
262
+
263
+ # Issue data with 2 old URLs and 1 new URL
264
+ issue_data = {
265
+ "body": """
266
+ Old: https://github.com/user-attachments/assets/old1
267
+ Old: https://github.com/user-attachments/assets/old2
268
+ New: https://github.com/user-attachments/assets/new1
269
+ """
270
+ }
271
+
272
+ downloaded_urls = setup["call_download"](issue_data)
273
+
274
+ # Should only download the NEW URL (old ones filtered by manifest)
275
+ assert len(downloaded_urls) == 1
276
+ assert downloaded_urls[0] == "https://github.com/user-attachments/assets/new1"
277
+
278
+ def test_no_manifest_downloads_all(self, attachment_test_setup):
279
+ """Without manifest, all URLs should be downloaded."""
280
+ setup = attachment_test_setup
281
+
282
+ # Issue data with 2 URLs
283
+ issue_data = {
284
+ "body": """
285
+ https://github.com/user-attachments/assets/url1
286
+ https://github.com/user-attachments/assets/url2
287
+ """
288
+ }
289
+
290
+ downloaded_urls = setup["call_download"](issue_data)
291
+
292
+ # Should download ALL URLs (no manifest to filter)
293
+ assert len(downloaded_urls) == 2
294
+ assert set(downloaded_urls) == {
295
+ "https://github.com/user-attachments/assets/url1",
296
+ "https://github.com/user-attachments/assets/url2",
297
+ }
298
+
299
+ def test_manifest_skips_permanent_failures(self, attachment_test_setup):
300
+ """Manifest skips permanent failures (404, 410) but retries transient (503)."""
301
+ setup = attachment_test_setup
302
+
303
+ # Create manifest with different failure types
304
+ attachments_dir = os.path.join(setup["issue_cwd"], "attachments", "123")
305
+ os.makedirs(attachments_dir)
306
+ manifest_path = os.path.join(attachments_dir, "manifest.json")
307
+
308
+ manifest = {
309
+ "attachments": [
310
+ {
311
+ "url": "https://github.com/user-attachments/assets/success",
312
+ "success": True,
313
+ "saved_as": "success.pdf",
314
+ },
315
+ {
316
+ "url": "https://github.com/user-attachments/assets/notfound",
317
+ "success": False,
318
+ "http_status": 404,
319
+ },
320
+ {
321
+ "url": "https://github.com/user-attachments/assets/gone",
322
+ "success": False,
323
+ "http_status": 410,
324
+ },
325
+ {
326
+ "url": "https://github.com/user-attachments/assets/unavailable",
327
+ "success": False,
328
+ "http_status": 503,
329
+ },
330
+ ]
331
+ }
332
+ with open(manifest_path, "w") as f:
333
+ json.dump(manifest, f)
334
+
335
+ # Issue data has all 4 URLs
336
+ issue_data = {
337
+ "body": """
338
+ https://github.com/user-attachments/assets/success
339
+ https://github.com/user-attachments/assets/notfound
340
+ https://github.com/user-attachments/assets/gone
341
+ https://github.com/user-attachments/assets/unavailable
342
+ """
343
+ }
344
+
345
+ downloaded_urls = setup["call_download"](issue_data)
346
+
347
+ # Should only retry 503 (transient failure)
348
+ # Success, 404, and 410 should be skipped
349
+ assert len(downloaded_urls) == 1
350
+ assert (
351
+ downloaded_urls[0]
352
+ == "https://github.com/user-attachments/assets/unavailable"
353
+ )
@@ -1 +0,0 @@
1
- __version__ = "0.51.0"
@@ -1 +0,0 @@
1
-
File without changes
File without changes
File without changes