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.
- {github_backup-0.51.0 → github_backup-0.51.2}/CHANGES.rst +117 -1
- {github_backup-0.51.0/github_backup.egg-info → github_backup-0.51.2}/PKG-INFO +1 -1
- github_backup-0.51.2/github_backup/__init__.py +1 -0
- {github_backup-0.51.0 → github_backup-0.51.2}/github_backup/github_backup.py +28 -26
- {github_backup-0.51.0 → github_backup-0.51.2/github_backup.egg-info}/PKG-INFO +1 -1
- {github_backup-0.51.0 → github_backup-0.51.2}/github_backup.egg-info/SOURCES.txt +2 -1
- {github_backup-0.51.0 → github_backup-0.51.2}/release-requirements.txt +4 -3
- github_backup-0.51.2/requirements.txt +0 -0
- github_backup-0.51.2/tests/test_attachments.py +353 -0
- github_backup-0.51.0/github_backup/__init__.py +0 -1
- github_backup-0.51.0/requirements.txt +0 -1
- {github_backup-0.51.0 → github_backup-0.51.2}/LICENSE.txt +0 -0
- {github_backup-0.51.0 → github_backup-0.51.2}/MANIFEST.in +0 -0
- {github_backup-0.51.0 → github_backup-0.51.2}/README +0 -0
- {github_backup-0.51.0 → github_backup-0.51.2}/README.rst +0 -0
- {github_backup-0.51.0 → github_backup-0.51.2}/bin/github-backup +0 -0
- {github_backup-0.51.0 → github_backup-0.51.2}/github_backup.egg-info/dependency_links.txt +0 -0
- {github_backup-0.51.0 → github_backup-0.51.2}/github_backup.egg-info/top_level.txt +0 -0
- {github_backup-0.51.0 → github_backup-0.51.2}/github_backup.egg-info/zip-safe +0 -0
- {github_backup-0.51.0 → github_backup-0.51.2}/setup.cfg +0 -0
- {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.
|
|
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]
|
|
@@ -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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
1345
|
+
# Load existing manifest to prevent duplicate downloads
|
|
1341
1346
|
existing_urls = set()
|
|
1342
1347
|
existing_metadata = []
|
|
1343
|
-
if
|
|
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,13 +1,14 @@
|
|
|
1
1
|
autopep8==2.3.2
|
|
2
|
-
black==25.
|
|
2
|
+
black==25.11.0
|
|
3
3
|
bleach==6.3.0
|
|
4
|
-
certifi==2025.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|