riffsdk 0.4.0__tar.gz → 0.4.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.
- {riffsdk-0.4.0 → riffsdk-0.4.2}/CHANGELOG.md +36 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/PKG-INFO +1 -1
- {riffsdk-0.4.0 → riffsdk-0.4.2}/mise.toml +1 -1
- {riffsdk-0.4.0 → riffsdk-0.4.2}/pyproject.toml +1 -1
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_async_client.py +2 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_client.py +2 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_upload.py +6 -2
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_async_client.py +93 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_client.py +77 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_integration.py +71 -2
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_upload.py +35 -2
- {riffsdk-0.4.0 → riffsdk-0.4.2}/.github/workflows/ci.yml +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/.github/workflows/pr-comment.yml +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/.github/workflows/release.yml +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/.gitignore +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/.python-version +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/AGENTS.md +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/CLAUDE.md +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/LICENCE +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/README.md +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/examples/async_client.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/examples/basic_crud.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/examples/file_upload_download.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/examples/optimistic_concurrency.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/__init__.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/__main__.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/_cli.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/_internal/__init__.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/_internal/auth.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/_internal/config.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/__init__.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_http.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_models.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_session.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_streaming.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/exceptions.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/py.typed +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/__init__.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_cli.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_exceptions.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_http.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_imports.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_models.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_session.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_streaming.py +0 -0
- {riffsdk-0.4.0 → riffsdk-0.4.2}/uv.lock +0 -0
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
<!-- version list -->
|
|
4
4
|
|
|
5
|
+
## v0.4.2 (2026-06-11)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
- Resume flow bugfixes ([#11](https://github.com/databutton/riff-sdk-python/pull/11),
|
|
10
|
+
[`21eee3f`](https://github.com/databutton/riff-sdk-python/commit/21eee3fb8ac7b7148c34f06119b1d361c191c808))
|
|
11
|
+
|
|
12
|
+
- Send correct content-type when uploading parts
|
|
13
|
+
([#11](https://github.com/databutton/riff-sdk-python/pull/11),
|
|
14
|
+
[`21eee3f`](https://github.com/databutton/riff-sdk-python/commit/21eee3fb8ac7b7148c34f06119b1d361c191c808))
|
|
15
|
+
|
|
16
|
+
### Chores
|
|
17
|
+
|
|
18
|
+
- Use 3.11 in mise file ([#11](https://github.com/databutton/riff-sdk-python/pull/11),
|
|
19
|
+
[`21eee3f`](https://github.com/databutton/riff-sdk-python/commit/21eee3fb8ac7b7148c34f06119b1d361c191c808))
|
|
20
|
+
|
|
21
|
+
### Testing
|
|
22
|
+
|
|
23
|
+
- Assert content-type is persisted after resumed multipart upload
|
|
24
|
+
([#11](https://github.com/databutton/riff-sdk-python/pull/11),
|
|
25
|
+
[`21eee3f`](https://github.com/databutton/riff-sdk-python/commit/21eee3fb8ac7b7148c34f06119b1d361c191c808))
|
|
26
|
+
|
|
27
|
+
- Cover content-type propagation through resumable uploads
|
|
28
|
+
([#11](https://github.com/databutton/riff-sdk-python/pull/11),
|
|
29
|
+
[`21eee3f`](https://github.com/databutton/riff-sdk-python/commit/21eee3fb8ac7b7148c34f06119b1d361c191c808))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
## v0.4.1 (2026-06-10)
|
|
33
|
+
|
|
34
|
+
### Bug Fixes
|
|
35
|
+
|
|
36
|
+
- Update integration test default base URL to include /riff-api
|
|
37
|
+
([#10](https://github.com/databutton/riff-sdk-python/pull/10),
|
|
38
|
+
[`6a18180`](https://github.com/databutton/riff-sdk-python/commit/6a181808176fb964e2d898045713e62f42681f47))
|
|
39
|
+
|
|
40
|
+
|
|
5
41
|
## v0.4.0 (2026-06-10)
|
|
6
42
|
|
|
7
43
|
### Bug Fixes
|
|
@@ -370,6 +370,7 @@ class AsyncStorageClient:
|
|
|
370
370
|
session_token=data["session_token"],
|
|
371
371
|
session_expires_at=datetime.fromisoformat(data["session_expires_at"]),
|
|
372
372
|
parts=parts,
|
|
373
|
+
content_type=content_type,
|
|
373
374
|
)
|
|
374
375
|
|
|
375
376
|
async def resume_upload(
|
|
@@ -399,6 +400,7 @@ class AsyncStorageClient:
|
|
|
399
400
|
session_token=data["session_token"],
|
|
400
401
|
session_expires_at=datetime.fromisoformat(data["session_expires_at"]),
|
|
401
402
|
parts=parts,
|
|
403
|
+
content_type=data.get("content_type", "application/octet-stream"),
|
|
402
404
|
)
|
|
403
405
|
|
|
404
406
|
# --- Download ---
|
|
@@ -370,6 +370,7 @@ class StorageClient:
|
|
|
370
370
|
session_token=data["session_token"],
|
|
371
371
|
session_expires_at=datetime.fromisoformat(data["session_expires_at"]),
|
|
372
372
|
parts=parts,
|
|
373
|
+
content_type=content_type,
|
|
373
374
|
)
|
|
374
375
|
|
|
375
376
|
def resume_upload(self, upload_id: str, *, scope: Scope = None) -> ResumableUpload:
|
|
@@ -397,6 +398,7 @@ class StorageClient:
|
|
|
397
398
|
session_token=data["session_token"],
|
|
398
399
|
session_expires_at=datetime.fromisoformat(data["session_expires_at"]),
|
|
399
400
|
parts=parts,
|
|
401
|
+
content_type=data.get("content_type", "application/octet-stream"),
|
|
400
402
|
)
|
|
401
403
|
|
|
402
404
|
# --- Download ---
|
|
@@ -26,6 +26,7 @@ class ResumableUpload:
|
|
|
26
26
|
session_token: str,
|
|
27
27
|
session_expires_at: datetime,
|
|
28
28
|
parts: list[UploadPart],
|
|
29
|
+
content_type: str = "application/octet-stream",
|
|
29
30
|
) -> None:
|
|
30
31
|
self._transport = transport
|
|
31
32
|
self._sessions = sessions
|
|
@@ -36,6 +37,7 @@ class ResumableUpload:
|
|
|
36
37
|
self.part_size = part_size
|
|
37
38
|
self.expires_at = expires_at
|
|
38
39
|
self.session_expires_at = session_expires_at
|
|
40
|
+
self.content_type = content_type
|
|
39
41
|
|
|
40
42
|
self._session_token = session_token
|
|
41
43
|
self._parts: dict[int, UploadPart] = {p.part_number: p for p in parts}
|
|
@@ -61,7 +63,7 @@ class ResumableUpload:
|
|
|
61
63
|
resp = self._transport.request_raw_put(
|
|
62
64
|
part.url,
|
|
63
65
|
content=data,
|
|
64
|
-
headers={"Content-Type":
|
|
66
|
+
headers={"Content-Type": self.content_type},
|
|
65
67
|
)
|
|
66
68
|
|
|
67
69
|
etag = resp.headers.get("ETag", "")
|
|
@@ -134,6 +136,7 @@ class AsyncResumableUpload:
|
|
|
134
136
|
session_token: str,
|
|
135
137
|
session_expires_at: datetime,
|
|
136
138
|
parts: list[UploadPart],
|
|
139
|
+
content_type: str = "application/octet-stream",
|
|
137
140
|
) -> None:
|
|
138
141
|
self._transport = transport
|
|
139
142
|
self._sessions = sessions
|
|
@@ -144,6 +147,7 @@ class AsyncResumableUpload:
|
|
|
144
147
|
self.part_size = part_size
|
|
145
148
|
self.expires_at = expires_at
|
|
146
149
|
self.session_expires_at = session_expires_at
|
|
150
|
+
self.content_type = content_type
|
|
147
151
|
|
|
148
152
|
self._session_token = session_token
|
|
149
153
|
self._parts: dict[int, UploadPart] = {p.part_number: p for p in parts}
|
|
@@ -169,7 +173,7 @@ class AsyncResumableUpload:
|
|
|
169
173
|
resp = await self._transport.request_raw_put(
|
|
170
174
|
part.url,
|
|
171
175
|
content=data,
|
|
172
|
-
headers={"Content-Type":
|
|
176
|
+
headers={"Content-Type": self.content_type},
|
|
173
177
|
)
|
|
174
178
|
|
|
175
179
|
etag = resp.headers.get("ETag", "")
|
|
@@ -874,3 +874,96 @@ class TestAsyncBeginUpload:
|
|
|
874
874
|
_, kwargs = mock_req.call_args
|
|
875
875
|
assert kwargs["json"]["key"] == "big.bin"
|
|
876
876
|
assert kwargs["json"]["size"] == 2048
|
|
877
|
+
|
|
878
|
+
@pytest.mark.asyncio
|
|
879
|
+
async def test_begin_upload_threads_content_type(self):
|
|
880
|
+
client = _make_client()
|
|
881
|
+
resp = _mock_response(
|
|
882
|
+
{
|
|
883
|
+
"upload_id": "u-1",
|
|
884
|
+
"key": "doc.txt",
|
|
885
|
+
"part_count": 1,
|
|
886
|
+
"part_size": 1024,
|
|
887
|
+
"expires_at": "2024-01-01T02:00:00Z",
|
|
888
|
+
"session_token": "sess-tok",
|
|
889
|
+
"session_expires_at": "2024-01-01T01:00:00Z",
|
|
890
|
+
"parts": [
|
|
891
|
+
{
|
|
892
|
+
"part_number": 1,
|
|
893
|
+
"byte_range_start": 0,
|
|
894
|
+
"byte_range_end": 1023,
|
|
895
|
+
"url": "https://signed.example.com/part1",
|
|
896
|
+
"url_expires_at": "2024-01-01T01:00:00Z",
|
|
897
|
+
},
|
|
898
|
+
],
|
|
899
|
+
}
|
|
900
|
+
)
|
|
901
|
+
with patch.object(
|
|
902
|
+
client._transport, "request", new_callable=AsyncMock, return_value=resp
|
|
903
|
+
):
|
|
904
|
+
upload = await client.begin_upload(
|
|
905
|
+
"doc.txt", total_size=1024, content_type="text/plain"
|
|
906
|
+
)
|
|
907
|
+
assert upload.content_type == "text/plain"
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
class TestAsyncResumeUpload:
|
|
911
|
+
@pytest.mark.asyncio
|
|
912
|
+
async def test_resume_upload_puts_with_server_content_type(self):
|
|
913
|
+
# Regression: the resume path rebuilds state purely from the server, so
|
|
914
|
+
# the part PUT must carry the content-type the refresh response reports
|
|
915
|
+
# (the GCS signed URL is bound to it).
|
|
916
|
+
client = _make_client()
|
|
917
|
+
refresh_resp = _mock_response(
|
|
918
|
+
{
|
|
919
|
+
"session_token": "new-tok",
|
|
920
|
+
"session_expires_at": "2024-01-01T03:00:00Z",
|
|
921
|
+
"content_type": "text/plain",
|
|
922
|
+
"parts": [
|
|
923
|
+
{
|
|
924
|
+
"part_number": 1,
|
|
925
|
+
"byte_range_start": 0,
|
|
926
|
+
"byte_range_end": 1023,
|
|
927
|
+
"url": "https://signed.example.com/part1",
|
|
928
|
+
"url_expires_at": "2024-01-01T03:00:00Z",
|
|
929
|
+
},
|
|
930
|
+
],
|
|
931
|
+
}
|
|
932
|
+
)
|
|
933
|
+
put_resp = MagicMock()
|
|
934
|
+
put_resp.headers = {"ETag": '"etag1"'}
|
|
935
|
+
with (
|
|
936
|
+
patch.object(
|
|
937
|
+
client._transport,
|
|
938
|
+
"request",
|
|
939
|
+
new_callable=AsyncMock,
|
|
940
|
+
return_value=refresh_resp,
|
|
941
|
+
),
|
|
942
|
+
patch.object(
|
|
943
|
+
client._transport,
|
|
944
|
+
"request_raw_put",
|
|
945
|
+
new_callable=AsyncMock,
|
|
946
|
+
return_value=put_resp,
|
|
947
|
+
) as mock_put,
|
|
948
|
+
):
|
|
949
|
+
upload = await client.resume_upload("u-old")
|
|
950
|
+
assert upload.content_type == "text/plain"
|
|
951
|
+
await upload.send_chunk(b"x" * 1024, part_number=1)
|
|
952
|
+
_, kwargs = mock_put.call_args
|
|
953
|
+
assert kwargs["headers"] == {"Content-Type": "text/plain"}
|
|
954
|
+
|
|
955
|
+
@pytest.mark.asyncio
|
|
956
|
+
async def test_resume_upload_defaults_content_type_when_absent(self):
|
|
957
|
+
client = _make_client()
|
|
958
|
+
resp = _mock_response(
|
|
959
|
+
{
|
|
960
|
+
"session_token": "new-tok",
|
|
961
|
+
"session_expires_at": "2024-01-01T03:00:00Z",
|
|
962
|
+
"parts": [],
|
|
963
|
+
}
|
|
964
|
+
)
|
|
965
|
+
with patch.object(
|
|
966
|
+
client._transport, "request", new_callable=AsyncMock, return_value=resp
|
|
967
|
+
):
|
|
968
|
+
upload = await client.resume_upload("u-old")
|
|
969
|
+
assert upload.content_type == "application/octet-stream"
|
|
@@ -867,6 +867,34 @@ class TestBeginUpload:
|
|
|
867
867
|
assert body["size"] == 2048
|
|
868
868
|
assert body["part_size"] == 1024
|
|
869
869
|
|
|
870
|
+
def test_begin_upload_threads_content_type(self):
|
|
871
|
+
client = _make_client()
|
|
872
|
+
resp = _mock_response(
|
|
873
|
+
{
|
|
874
|
+
"upload_id": "u-1",
|
|
875
|
+
"key": "doc.txt",
|
|
876
|
+
"part_count": 1,
|
|
877
|
+
"part_size": 1024,
|
|
878
|
+
"expires_at": "2024-01-01T02:00:00Z",
|
|
879
|
+
"session_token": "sess-tok",
|
|
880
|
+
"session_expires_at": "2024-01-01T01:00:00Z",
|
|
881
|
+
"parts": [
|
|
882
|
+
{
|
|
883
|
+
"part_number": 1,
|
|
884
|
+
"byte_range_start": 0,
|
|
885
|
+
"byte_range_end": 1023,
|
|
886
|
+
"url": "https://signed.example.com/part1",
|
|
887
|
+
"url_expires_at": "2024-01-01T01:00:00Z",
|
|
888
|
+
},
|
|
889
|
+
],
|
|
890
|
+
}
|
|
891
|
+
)
|
|
892
|
+
with patch.object(client._transport, "request", return_value=resp):
|
|
893
|
+
upload = client.begin_upload(
|
|
894
|
+
"doc.txt", total_size=1024, content_type="text/plain"
|
|
895
|
+
)
|
|
896
|
+
assert upload.content_type == "text/plain"
|
|
897
|
+
|
|
870
898
|
def test_begin_upload_with_conditions(self):
|
|
871
899
|
client = _make_client()
|
|
872
900
|
resp = _mock_response(
|
|
@@ -920,6 +948,55 @@ class TestResumeUpload:
|
|
|
920
948
|
args, kwargs = mock_req.call_args
|
|
921
949
|
assert args == ("POST", "/storage/v1/uploads/u-old/refresh")
|
|
922
950
|
|
|
951
|
+
def test_resume_upload_puts_with_server_content_type(self):
|
|
952
|
+
# Regression: the resume path rebuilds state purely from the server, so
|
|
953
|
+
# the part PUT must carry the content-type the refresh response reports
|
|
954
|
+
# (the GCS signed URL is bound to it). Hardcoding octet-stream here
|
|
955
|
+
# caused a 403 for non-default content-types.
|
|
956
|
+
client = _make_client()
|
|
957
|
+
refresh_resp = _mock_response(
|
|
958
|
+
{
|
|
959
|
+
"session_token": "new-tok",
|
|
960
|
+
"session_expires_at": "2024-01-01T03:00:00Z",
|
|
961
|
+
"content_type": "text/plain",
|
|
962
|
+
"parts": [
|
|
963
|
+
{
|
|
964
|
+
"part_number": 1,
|
|
965
|
+
"byte_range_start": 0,
|
|
966
|
+
"byte_range_end": 1023,
|
|
967
|
+
"url": "https://signed.example.com/part1",
|
|
968
|
+
"url_expires_at": "2024-01-01T03:00:00Z",
|
|
969
|
+
},
|
|
970
|
+
],
|
|
971
|
+
}
|
|
972
|
+
)
|
|
973
|
+
put_resp = MagicMock()
|
|
974
|
+
put_resp.headers = {"ETag": '"etag1"'}
|
|
975
|
+
with (
|
|
976
|
+
patch.object(client._transport, "request", return_value=refresh_resp),
|
|
977
|
+
patch.object(
|
|
978
|
+
client._transport, "request_raw_put", return_value=put_resp
|
|
979
|
+
) as mock_put,
|
|
980
|
+
):
|
|
981
|
+
upload = client.resume_upload("u-old")
|
|
982
|
+
assert upload.content_type == "text/plain"
|
|
983
|
+
upload.send_chunk(b"x" * 1024, part_number=1)
|
|
984
|
+
_, kwargs = mock_put.call_args
|
|
985
|
+
assert kwargs["headers"] == {"Content-Type": "text/plain"}
|
|
986
|
+
|
|
987
|
+
def test_resume_upload_defaults_content_type_when_absent(self):
|
|
988
|
+
client = _make_client()
|
|
989
|
+
resp = _mock_response(
|
|
990
|
+
{
|
|
991
|
+
"session_token": "new-tok",
|
|
992
|
+
"session_expires_at": "2024-01-01T03:00:00Z",
|
|
993
|
+
"parts": [],
|
|
994
|
+
}
|
|
995
|
+
)
|
|
996
|
+
with patch.object(client._transport, "request", return_value=resp):
|
|
997
|
+
upload = client.resume_upload("u-old")
|
|
998
|
+
assert upload.content_type == "application/octet-stream"
|
|
999
|
+
|
|
923
1000
|
|
|
924
1001
|
class TestUploadFile:
|
|
925
1002
|
def test_upload_file_orchestrates_upload(self):
|
|
@@ -6,7 +6,7 @@ Prerequisites:
|
|
|
6
6
|
|
|
7
7
|
Configuration via environment variables:
|
|
8
8
|
RIFF_INTEGRATION_TEST=true (required — tests are skipped without this)
|
|
9
|
-
RIFF_BASE_URL (default: http://localhost:8080)
|
|
9
|
+
RIFF_BASE_URL (default: http://localhost:8080/riff-api)
|
|
10
10
|
RIFF_API_KEY (default: test-key)
|
|
11
11
|
RIFF_PROJECT_ID (default: test-project)
|
|
12
12
|
"""
|
|
@@ -30,7 +30,7 @@ from riffsdk.storage.exceptions import (
|
|
|
30
30
|
VersionConflictError,
|
|
31
31
|
)
|
|
32
32
|
|
|
33
|
-
BASE_URL = os.environ.get("RIFF_BASE_URL", "http://localhost:8080")
|
|
33
|
+
BASE_URL = os.environ.get("RIFF_BASE_URL", "http://localhost:8080/riff-api")
|
|
34
34
|
API_KEY = os.environ.get("RIFF_API_KEY", "test-key")
|
|
35
35
|
PROJECT_ID = os.environ.get("RIFF_PROJECT_ID", "test-project")
|
|
36
36
|
|
|
@@ -321,6 +321,41 @@ class TestResumeUpload:
|
|
|
321
321
|
data = client.get(key)
|
|
322
322
|
assert data == content
|
|
323
323
|
|
|
324
|
+
def test_resume_with_non_default_content_type(self, client: StorageClient, prefix):
|
|
325
|
+
# Regression: GCS signed URLs are bound to the content-type passed at
|
|
326
|
+
# begin_upload. The resume path rebuilds state from the server, so the
|
|
327
|
+
# part PUT must carry that same content-type or GCS returns 403.
|
|
328
|
+
key = f"{prefix}resume-text.txt"
|
|
329
|
+
chunk_size = 1024 * 1024
|
|
330
|
+
content = b"resume-content-type-check " * 50_000 # > 2 chunks
|
|
331
|
+
content = content[: 2 * chunk_size]
|
|
332
|
+
|
|
333
|
+
upload = client.begin_upload(
|
|
334
|
+
key,
|
|
335
|
+
total_size=len(content),
|
|
336
|
+
content_type="text/plain",
|
|
337
|
+
chunk_size=chunk_size,
|
|
338
|
+
)
|
|
339
|
+
upload_id = upload.upload_id
|
|
340
|
+
upload.send_chunk(content[:chunk_size], part_number=1)
|
|
341
|
+
|
|
342
|
+
resumed = client.resume_upload(upload_id)
|
|
343
|
+
# The server reports the upload's content-type on refresh, and the SDK
|
|
344
|
+
# uses it for the part PUT.
|
|
345
|
+
assert resumed.content_type == "text/plain"
|
|
346
|
+
# This PUT 403'd before the fix (hardcoded application/octet-stream).
|
|
347
|
+
resumed.send_chunk(content[chunk_size:], part_number=2)
|
|
348
|
+
|
|
349
|
+
all_etags = {**upload._etags, **resumed._etags}
|
|
350
|
+
parts = [(num, etag) for num, etag in sorted(all_etags.items())]
|
|
351
|
+
result = resumed.complete(parts=parts)
|
|
352
|
+
assert result.key == key
|
|
353
|
+
|
|
354
|
+
# The bytes round-trip — the resumed PUT was accepted by GCS.
|
|
355
|
+
assert client.get(key) == content
|
|
356
|
+
# The uploaded content-type is persisted on the stored object.
|
|
357
|
+
assert client.stat(key).content_type == "text/plain"
|
|
358
|
+
|
|
324
359
|
|
|
325
360
|
class TestListDelimiter:
|
|
326
361
|
def test_list_with_delimiter(self, client: StorageClient, prefix: str):
|
|
@@ -838,6 +873,40 @@ class TestAsyncResumeUpload:
|
|
|
838
873
|
data = await async_client.get(key)
|
|
839
874
|
assert data == content
|
|
840
875
|
|
|
876
|
+
@pytest.mark.asyncio
|
|
877
|
+
async def test_resume_with_non_default_content_type(
|
|
878
|
+
self, async_client: AsyncStorageClient, async_prefix: str
|
|
879
|
+
):
|
|
880
|
+
# Regression: the resumed part PUT must carry the original content-type
|
|
881
|
+
# the GCS signed URL was bound to, or GCS returns 403.
|
|
882
|
+
key = f"{async_prefix}resume-text.txt"
|
|
883
|
+
chunk_size = 1024 * 1024
|
|
884
|
+
content = b"resume-content-type-check " * 50_000
|
|
885
|
+
content = content[: 2 * chunk_size]
|
|
886
|
+
|
|
887
|
+
upload = await async_client.begin_upload(
|
|
888
|
+
key,
|
|
889
|
+
total_size=len(content),
|
|
890
|
+
content_type="text/plain",
|
|
891
|
+
chunk_size=chunk_size,
|
|
892
|
+
)
|
|
893
|
+
upload_id = upload.upload_id
|
|
894
|
+
await upload.send_chunk(content[:chunk_size], part_number=1)
|
|
895
|
+
|
|
896
|
+
resumed = await async_client.resume_upload(upload_id)
|
|
897
|
+
assert resumed.content_type == "text/plain"
|
|
898
|
+
await resumed.send_chunk(content[chunk_size:], part_number=2)
|
|
899
|
+
|
|
900
|
+
all_etags = {**upload._etags, **resumed._etags}
|
|
901
|
+
parts = [(num, etag) for num, etag in sorted(all_etags.items())]
|
|
902
|
+
result = await resumed.complete(parts=parts)
|
|
903
|
+
assert result.key == key
|
|
904
|
+
|
|
905
|
+
# The bytes round-trip — the resumed PUT was accepted by GCS.
|
|
906
|
+
assert await async_client.get(key) == content
|
|
907
|
+
# The uploaded content-type is persisted on the stored object.
|
|
908
|
+
assert (await async_client.stat(key)).content_type == "text/plain"
|
|
909
|
+
|
|
841
910
|
|
|
842
911
|
class TestAsyncResumableUpload:
|
|
843
912
|
@pytest.mark.asyncio
|
|
@@ -26,7 +26,10 @@ def _make_parts(count: int = 2) -> list[UploadPart]:
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def _make_sync_upload(
|
|
29
|
-
*,
|
|
29
|
+
*,
|
|
30
|
+
transport: MagicMock | None = None,
|
|
31
|
+
sessions: SessionStore | None = None,
|
|
32
|
+
content_type: str = "application/octet-stream",
|
|
30
33
|
) -> ResumableUpload:
|
|
31
34
|
transport = transport or MagicMock()
|
|
32
35
|
sessions = sessions or SessionStore()
|
|
@@ -42,11 +45,15 @@ def _make_sync_upload(
|
|
|
42
45
|
session_token="sess-tok",
|
|
43
46
|
session_expires_at=_FAR_FUTURE,
|
|
44
47
|
parts=_make_parts(),
|
|
48
|
+
content_type=content_type,
|
|
45
49
|
)
|
|
46
50
|
|
|
47
51
|
|
|
48
52
|
def _make_async_upload(
|
|
49
|
-
*,
|
|
53
|
+
*,
|
|
54
|
+
transport: MagicMock | None = None,
|
|
55
|
+
sessions: SessionStore | None = None,
|
|
56
|
+
content_type: str = "application/octet-stream",
|
|
50
57
|
) -> AsyncResumableUpload:
|
|
51
58
|
transport = transport or MagicMock()
|
|
52
59
|
sessions = sessions or SessionStore()
|
|
@@ -62,6 +69,7 @@ def _make_async_upload(
|
|
|
62
69
|
session_token="sess-tok",
|
|
63
70
|
session_expires_at=_FAR_FUTURE,
|
|
64
71
|
parts=_make_parts(),
|
|
72
|
+
content_type=content_type,
|
|
65
73
|
)
|
|
66
74
|
|
|
67
75
|
|
|
@@ -138,6 +146,18 @@ class TestSendChunk:
|
|
|
138
146
|
with pytest.raises(StorageTransportError):
|
|
139
147
|
upload.send_chunk(b"x" * 1024)
|
|
140
148
|
|
|
149
|
+
def test_uses_upload_content_type_header(self):
|
|
150
|
+
transport = MagicMock()
|
|
151
|
+
put_resp = MagicMock()
|
|
152
|
+
put_resp.headers = {"ETag": '"etag1"'}
|
|
153
|
+
transport.request_raw_put.return_value = put_resp
|
|
154
|
+
|
|
155
|
+
upload = _make_sync_upload(transport=transport, content_type="text/plain")
|
|
156
|
+
upload.send_chunk(b"x" * 1024)
|
|
157
|
+
|
|
158
|
+
_, kwargs = transport.request_raw_put.call_args
|
|
159
|
+
assert kwargs["headers"] == {"Content-Type": "text/plain"}
|
|
160
|
+
|
|
141
161
|
|
|
142
162
|
class TestRefreshSession:
|
|
143
163
|
def test_updates_token_and_parts(self):
|
|
@@ -285,6 +305,19 @@ class TestAsyncSendChunk:
|
|
|
285
305
|
with pytest.raises(StorageTransportError):
|
|
286
306
|
await upload.send_chunk(b"x" * 1024)
|
|
287
307
|
|
|
308
|
+
@pytest.mark.asyncio
|
|
309
|
+
async def test_uses_upload_content_type_header(self):
|
|
310
|
+
transport = MagicMock()
|
|
311
|
+
put_resp = MagicMock()
|
|
312
|
+
put_resp.headers = {"ETag": '"etag1"'}
|
|
313
|
+
transport.request_raw_put = AsyncMock(return_value=put_resp)
|
|
314
|
+
|
|
315
|
+
upload = _make_async_upload(transport=transport, content_type="text/plain")
|
|
316
|
+
await upload.send_chunk(b"x" * 1024)
|
|
317
|
+
|
|
318
|
+
_, kwargs = transport.request_raw_put.call_args
|
|
319
|
+
assert kwargs["headers"] == {"Content-Type": "text/plain"}
|
|
320
|
+
|
|
288
321
|
|
|
289
322
|
class TestAsyncRefreshSession:
|
|
290
323
|
@pytest.mark.asyncio
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|