github-backup 0.51.0__tar.gz → 0.51.1__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.
@@ -1,10 +1,98 @@
1
1
  Changelog
2
2
  =========
3
3
 
4
- 0.51.0 (2025-11-06)
4
+ 0.51.1 (2025-11-16)
5
5
  -------------------
6
6
  ------------------------
7
7
 
8
+ Fix
9
+ ~~~
10
+ - Prevent duplicate attachment downloads. [Rodos]
11
+
12
+ Fixes bug where attachments were downloaded multiple times with
13
+ incremented filenames (file.mov, file_1.mov, file_2.mov) when
14
+ running backups without --skip-existing flag.
15
+
16
+ I should not have used the --skip-existing flag for attachments,
17
+ it did not do what I thought it did.
18
+
19
+ The correct approach is to always use the manifest to guide what
20
+ has already been downloaded and what now needs to be done.
21
+
22
+ Other
23
+ ~~~~~
24
+ - Chore(deps): bump certifi in the python-packages group.
25
+ [dependabot[bot]]
26
+
27
+ Bumps the python-packages group with 1 update: [certifi](https://github.com/certifi/python-certifi).
28
+
29
+
30
+ Updates `certifi` from 2025.10.5 to 2025.11.12
31
+ - [Commits](https://github.com/certifi/python-certifi/compare/2025.10.05...2025.11.12)
32
+
33
+ ---
34
+ updated-dependencies:
35
+ - dependency-name: certifi
36
+ dependency-version: 2025.11.12
37
+ dependency-type: direct:production
38
+ update-type: version-update:semver-minor
39
+ dependency-group: python-packages
40
+ ...
41
+ - Test: Add pytest infrastructure and attachment tests. [Rodos]
42
+
43
+ In making my last fix to attachments, I found it challenging not
44
+ having tests to ensure there was no regression.
45
+
46
+ Added pytest with minimal setup and isolated configuration. Created
47
+ a separate test workflow to keep tests isolated from linting.
48
+
49
+ Tests cover the key elements of the attachment logic:
50
+ - URL extraction from issue bodies
51
+ - Filename extraction from different URL types
52
+ - Filename collision resolution
53
+ - Manifest duplicate prevention
54
+ - Chore(deps): bump black in the python-packages group.
55
+ [dependabot[bot]]
56
+
57
+ Bumps the python-packages group with 1 update: [black](https://github.com/psf/black).
58
+
59
+
60
+ Updates `black` from 25.9.0 to 25.11.0
61
+ - [Release notes](https://github.com/psf/black/releases)
62
+ - [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
63
+ - [Commits](https://github.com/psf/black/compare/25.9.0...25.11.0)
64
+
65
+ ---
66
+ updated-dependencies:
67
+ - dependency-name: black
68
+ dependency-version: 25.11.0
69
+ dependency-type: direct:production
70
+ update-type: version-update:semver-minor
71
+ dependency-group: python-packages
72
+ ...
73
+ - Chore(deps): bump docutils in the python-packages group.
74
+ [dependabot[bot]]
75
+
76
+ Bumps the python-packages group with 1 update: [docutils](https://github.com/rtfd/recommonmark).
77
+
78
+
79
+ Updates `docutils` from 0.22.2 to 0.22.3
80
+ - [Changelog](https://github.com/readthedocs/recommonmark/blob/master/CHANGELOG.md)
81
+ - [Commits](https://github.com/rtfd/recommonmark/commits)
82
+
83
+ ---
84
+ updated-dependencies:
85
+ - dependency-name: docutils
86
+ dependency-version: 0.22.3
87
+ dependency-type: direct:production
88
+ update-type: version-update:semver-patch
89
+ dependency-group: python-packages
90
+ ...
91
+
92
+
93
+ 0.51.0 (2025-11-06)
94
+ -------------------
95
+
8
96
  Fix
9
97
  ~~~
10
98
  - 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.1
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.1"
@@ -919,12 +919,6 @@ def download_attachment_file(url, path, auth, as_app=False, fine=False):
919
919
  "error": None,
920
920
  }
921
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
922
  # Create simple request (no API query params)
929
923
  request = Request(url)
930
924
  request.add_header("Accept", "application/octet-stream")
@@ -1337,10 +1331,10 @@ def download_attachments(
1337
1331
  attachments_dir = os.path.join(item_cwd, "attachments", str(number))
1338
1332
  manifest_path = os.path.join(attachments_dir, "manifest.json")
1339
1333
 
1340
- # Load existing manifest if skip_existing is enabled
1334
+ # Load existing manifest to prevent duplicate downloads
1341
1335
  existing_urls = set()
1342
1336
  existing_metadata = []
1343
- if args.skip_existing and os.path.exists(manifest_path):
1337
+ if os.path.exists(manifest_path):
1344
1338
  try:
1345
1339
  with open(manifest_path, "r") as f:
1346
1340
  existing_manifest = json.load(f)
@@ -1395,9 +1389,6 @@ def download_attachments(
1395
1389
  filename = get_attachment_filename(url)
1396
1390
  filepath = os.path.join(attachments_dir, filename)
1397
1391
 
1398
- # Check for collision BEFORE downloading
1399
- filepath = resolve_filename_collision(filepath)
1400
-
1401
1392
  # Download and get metadata
1402
1393
  metadata = download_attachment_file(
1403
1394
  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.1
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
@@ -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"
File without changes
File without changes
File without changes