Skip to content

Commit cc01e6c

Browse files
committed
Add graph media uploads to the Python client
1 parent 3d01f7c commit cc01e6c

3 files changed

Lines changed: 60 additions & 1 deletion

File tree

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "subfork"
7-
version = "2.0.1"
7+
version = "2.0.2"
88
description = "Python API client for Subfork graph authoring and execution"
99
readme = "README.md"
1010
license = {file = "LICENSE"}

‎src/subfork/resources.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,33 @@ def create(
9595
},
9696
)
9797

98+
def upload_media(
99+
self,
100+
graph_id: str,
101+
purpose: str,
102+
file_path: Union[str, Path],
103+
*,
104+
replace_id: Optional[str] = None,
105+
) -> Dict[str, Any]:
106+
"""Upload a thumbnail, preview, or screenshot to an owned graph.
107+
108+
Requires uploads. Graph media is stored by the server and counts toward
109+
graph media quotas. The request is not automatically retried.
110+
"""
111+
if purpose not in {"thumbnail", "preview", "screenshot"}:
112+
raise ValueError("purpose must be thumbnail, preview, or screenshot.")
113+
if replace_id is not None and (purpose != "screenshot" or not replace_id):
114+
raise ValueError("replace_id is supported only for screenshots.")
115+
path = Path(file_path)
116+
media_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
117+
with path.open("rb") as stream:
118+
return self._client._request(
119+
"POST",
120+
"/graphs/" + segment(graph_id) + "/media/" + purpose,
121+
params={"replace_id": replace_id} if replace_id else None,
122+
files={"upload": (path.name, stream, media_type)},
123+
)
124+
98125
def update(
99126
self, graph_id: str, *, name: str, definition: Dict[str, Any], description: str = ""
100127
) -> Dict[str, Any]:

‎tests/test_client.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Exercise client contracts without network access or real credentials."""
22

33
import json
4+
from pathlib import Path
45
from typing import Any, Type
56

67
import httpx
@@ -141,6 +142,37 @@ def handle(request: httpx.Request) -> httpx.Response:
141142
assert seen[-1][2:] == ({"version": "v1"}, {"inputs": {"value": 3}})
142143

143144

145+
def test_graph_media_upload_uses_multipart(tmp_path: Path) -> None:
146+
"""Upload graph media as a typed multipart file without retrying."""
147+
image = tmp_path / "thumbnail.jpg"
148+
image.write_bytes(b"jpeg-bytes")
149+
seen = []
150+
151+
def handle(request: httpx.Request) -> httpx.Response:
152+
seen.append(request)
153+
return httpx.Response(201, json={"artifact_id": "art_thumb"})
154+
155+
with Subfork(KEY, transport=httpx.MockTransport(handle)) as client:
156+
result = client.graphs.upload_media("g_test", "thumbnail", image)
157+
replacement = client.graphs.upload_media(
158+
"g_test", "screenshot", image, replace_id="art_old"
159+
)
160+
with pytest.raises(ValueError, match="purpose"):
161+
client.graphs.upload_media("g_test", "unknown", image)
162+
163+
assert result == replacement == {"artifact_id": "art_thumb"}
164+
assert len(seen) == 2
165+
assert seen[0].method == "POST"
166+
assert seen[0].url.path == "/api/v1/graphs/g_test/media/thumbnail"
167+
assert str(seen[0].url.params) == ""
168+
assert seen[1].url.path == "/api/v1/graphs/g_test/media/screenshot"
169+
assert str(seen[1].url.params) == "replace_id=art_old"
170+
assert seen[0].headers["content-type"].startswith("multipart/form-data;")
171+
assert b'filename="thumbnail.jpg"' in seen[0].content
172+
assert b"image/jpeg" in seen[0].content
173+
assert b"jpeg-bytes" in seen[0].content
174+
175+
144176
def test_transport_failure_is_not_retried() -> None:
145177
"""Verify transport failure is not retried."""
146178
seen = []

0 commit comments

Comments
 (0)