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.
Files changed (46) hide show
  1. {riffsdk-0.4.0 → riffsdk-0.4.2}/CHANGELOG.md +36 -0
  2. {riffsdk-0.4.0 → riffsdk-0.4.2}/PKG-INFO +1 -1
  3. {riffsdk-0.4.0 → riffsdk-0.4.2}/mise.toml +1 -1
  4. {riffsdk-0.4.0 → riffsdk-0.4.2}/pyproject.toml +1 -1
  5. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_async_client.py +2 -0
  6. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_client.py +2 -0
  7. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_upload.py +6 -2
  8. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_async_client.py +93 -0
  9. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_client.py +77 -0
  10. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_integration.py +71 -2
  11. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_upload.py +35 -2
  12. {riffsdk-0.4.0 → riffsdk-0.4.2}/.github/workflows/ci.yml +0 -0
  13. {riffsdk-0.4.0 → riffsdk-0.4.2}/.github/workflows/pr-comment.yml +0 -0
  14. {riffsdk-0.4.0 → riffsdk-0.4.2}/.github/workflows/release.yml +0 -0
  15. {riffsdk-0.4.0 → riffsdk-0.4.2}/.gitignore +0 -0
  16. {riffsdk-0.4.0 → riffsdk-0.4.2}/.python-version +0 -0
  17. {riffsdk-0.4.0 → riffsdk-0.4.2}/AGENTS.md +0 -0
  18. {riffsdk-0.4.0 → riffsdk-0.4.2}/CLAUDE.md +0 -0
  19. {riffsdk-0.4.0 → riffsdk-0.4.2}/LICENCE +0 -0
  20. {riffsdk-0.4.0 → riffsdk-0.4.2}/README.md +0 -0
  21. {riffsdk-0.4.0 → riffsdk-0.4.2}/examples/async_client.py +0 -0
  22. {riffsdk-0.4.0 → riffsdk-0.4.2}/examples/basic_crud.py +0 -0
  23. {riffsdk-0.4.0 → riffsdk-0.4.2}/examples/file_upload_download.py +0 -0
  24. {riffsdk-0.4.0 → riffsdk-0.4.2}/examples/optimistic_concurrency.py +0 -0
  25. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/__init__.py +0 -0
  26. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/__main__.py +0 -0
  27. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/_cli.py +0 -0
  28. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/_internal/__init__.py +0 -0
  29. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/_internal/auth.py +0 -0
  30. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/_internal/config.py +0 -0
  31. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/__init__.py +0 -0
  32. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_http.py +0 -0
  33. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_models.py +0 -0
  34. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_session.py +0 -0
  35. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/_streaming.py +0 -0
  36. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/exceptions.py +0 -0
  37. {riffsdk-0.4.0 → riffsdk-0.4.2}/src/riffsdk/storage/py.typed +0 -0
  38. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/__init__.py +0 -0
  39. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_cli.py +0 -0
  40. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_exceptions.py +0 -0
  41. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_http.py +0 -0
  42. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_imports.py +0 -0
  43. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_models.py +0 -0
  44. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_session.py +0 -0
  45. {riffsdk-0.4.0 → riffsdk-0.4.2}/tests/test_streaming.py +0 -0
  46. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: riffsdk
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Python SDK for the Riff Storage API
5
5
  License-File: LICENCE
6
6
  Requires-Python: >=3.11
@@ -1,5 +1,5 @@
1
1
  [tools]
2
- python = "3.13"
2
+ python = "3.11"
3
3
  uv = "latest"
4
4
 
5
5
  [tasks.test]
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "riffsdk"
7
- version = "0.4.0"
7
+ version = "0.4.2"
8
8
  description = "Python SDK for the Riff Storage API"
9
9
  requires-python = ">=3.11"
10
10
  dependencies = ["httpx>=0.27", "pydantic>=2.6", "tenacity>=8.3"]
@@ -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": "application/octet-stream"},
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": "application/octet-stream"},
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
- *, transport: MagicMock | None = None, sessions: SessionStore | None = None
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
- *, transport: MagicMock | None = None, sessions: SessionStore | None = None
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