Skip to content

Commit f06a128

Browse files
committed
add bulk_create_parsed() for typed results from bulk create operations
1 parent 1263a21 commit f06a128

5 files changed

Lines changed: 123 additions & 4 deletions

File tree

src/bubble_data_api_client/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from bubble_data_api_client.types import (
2525
BubbleField,
2626
BubbleUID,
27+
BulkCreateItemResult,
2728
OnMultiple,
2829
OptionalBubbleUID,
2930
OptionalBubbleUIDs,
@@ -51,6 +52,7 @@
5152
# types
5253
"BubbleField",
5354
"BubbleUID",
55+
"BulkCreateItemResult",
5456
"OnMultiple",
5557
"OptionalBubbleUID",
5658
"OptionalBubbleUIDs",

src/bubble_data_api_client/client/raw_client.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@
2020
PartialFailureError,
2121
)
2222
from bubble_data_api_client.transport import Transport
23-
from bubble_data_api_client.types import BubbleField, CreateOrUpdateResult, OnMultiple
23+
from bubble_data_api_client.types import (
24+
BubbleField,
25+
BulkCreateItemResult,
26+
CreateOrUpdateResult,
27+
OnMultiple,
28+
)
2429

2530

2631
# https://manual.bubble.io/core-resources/api/the-bubble-api/the-data-api/data-api-requests#sorting
@@ -48,7 +53,7 @@ class RawClient:
4853
These handle response parsing and return typed values. Use these when you
4954
don't need raw HTTP access.
5055
51-
count, exists, create_or_update
56+
count, exists, create_or_update, bulk_create_parsed
5257
5358
For ORM-style access with model classes, use BubbleModel instead.
5459
@@ -89,13 +94,36 @@ async def create(self, typename: str, data: typing.Any) -> httpx.Response:
8994
async def bulk_create(self, typename: str, data: list[typing.Any]) -> httpx.Response:
9095
"""Create multiple things in a single request using newline-delimited JSON.
9196
92-
Response is text/plain with one JSON object per line: {"status":"success","id":"..."}
97+
Response is text/plain with one JSON object per line, in the same order as input:
98+
Success: {"status":"success","id":"1234x5678"}
99+
Error: {"status":"error","message":"Could not parse as JSON: ..."}
100+
101+
Partial failures are possible where some items succeed and others fail.
93102
"""
94103
return await self._transport.post_text(
95104
url=f"/{typename}/bulk",
96105
content="\n".join(json.dumps(item) for item in data),
97106
)
98107

108+
async def bulk_create_parsed(self, typename: str, data: list[typing.Any]) -> list[BulkCreateItemResult]:
109+
"""Create multiple things and return parsed results for each item.
110+
111+
Returns a list of results in the same order as input data. Each result
112+
contains status, id (on success), and message (on error).
113+
"""
114+
response = await self.bulk_create(typename=typename, data=data)
115+
results: list[BulkCreateItemResult] = []
116+
for line in response.text.strip().split("\n"):
117+
parsed = json.loads(line)
118+
results.append(
119+
BulkCreateItemResult(
120+
status=parsed["status"],
121+
id=parsed.get("id"),
122+
message=parsed.get("message"),
123+
)
124+
)
125+
return results
126+
99127
async def delete(self, typename: str, uid: str) -> httpx.Response:
100128
"""Delete a thing by its unique ID."""
101129
return await self._transport.delete(f"/{typename}/{uid}")

src/bubble_data_api_client/types.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Bubble platform types for use with Pydantic models."""
22

33
from enum import StrEnum
4-
from typing import Annotated, Any, TypedDict
4+
from typing import Annotated, Any, Literal, TypedDict
55

66
from pydantic import AfterValidator, BeforeValidator
77

@@ -37,6 +37,18 @@ class CreateOrUpdateResult(TypedDict):
3737
created: bool
3838

3939

40+
class BulkCreateItemResult(TypedDict):
41+
"""Result for a single item in a bulk create operation.
42+
43+
On success: status="success", id=<uid>, message=None
44+
On error: status="error", id=None, message=<error description>
45+
"""
46+
47+
status: Literal["success", "error"]
48+
id: str | None
49+
message: str | None
50+
51+
4052
def _validate_bubble_uid(value: str) -> str:
4153
"""Validate that a string is a valid Bubble UID."""
4254
if not is_bubble_uid(value):

src/tests/integration/test_raw_client.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,37 @@ async def test_bulk_create_success(typename: str, bubble_raw_client: raw_client.
7979
warnings.warn(f"cleanup failed for {uid}: {e}", stacklevel=2)
8080

8181

82+
async def test_bulk_create_parsed_success(typename: str, bubble_raw_client: raw_client.RawClient):
83+
"""Test that bulk_create_parsed returns typed results."""
84+
created_ids: list[str] = []
85+
86+
try:
87+
results = await bubble_raw_client.bulk_create_parsed(
88+
typename=typename,
89+
data=[{"text": "parsed test 1"}, {"text": "parsed test 2"}],
90+
)
91+
92+
assert len(results) == 2
93+
for result in results:
94+
assert result["status"] == "success"
95+
assert result["id"] is not None
96+
assert result["message"] is None
97+
created_ids.append(result["id"])
98+
99+
# verify items exist with correct data
100+
expected_texts = ["parsed test 1", "parsed test 2"]
101+
for uid, expected_text in zip(created_ids, expected_texts, strict=True):
102+
retrieve_response = await bubble_raw_client.retrieve(typename=typename, uid=uid)
103+
assert retrieve_response.json()["response"]["text"] == expected_text
104+
105+
finally:
106+
for uid in created_ids:
107+
try:
108+
await bubble_raw_client.delete(typename=typename, uid=uid)
109+
except Exception as e:
110+
warnings.warn(f"cleanup failed for {uid}: {e}", stacklevel=2)
111+
112+
82113
async def test_update_success(typename: str, test_thing_id: str, bubble_raw_client: raw_client.RawClient):
83114
"""Test that we can update a thing."""
84115
response = await bubble_raw_client.update(typename=typename, uid=test_thing_id, data={"text": "updated text"})

src/tests/unit/client/test_raw_client.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,52 @@ async def test_bulk_create(configured_client: None) -> None:
6060
assert request_content == '{"name": "Alice"}\n{"name": "Bob"}'
6161

6262

63+
@respx.mock
64+
async def test_bulk_create_parsed_success(configured_client: None) -> None:
65+
"""Test that bulk_create_parsed returns parsed results on success."""
66+
mock_response_text = '{"status":"success","id":"1234x5678"}\n{"status":"success","id":"1234x5679"}'
67+
respx.post("https://example.com/customer/bulk").mock(
68+
return_value=httpx.Response(200, text=mock_response_text, headers={"content-type": "text/plain"})
69+
)
70+
71+
async with raw_client.RawClient() as client:
72+
results = await client.bulk_create_parsed(
73+
typename="customer",
74+
data=[{"name": "Alice"}, {"name": "Bob"}],
75+
)
76+
77+
assert len(results) == 2
78+
assert results[0]["status"] == "success"
79+
assert results[0]["id"] == "1234x5678"
80+
assert results[0]["message"] is None
81+
assert results[1]["status"] == "success"
82+
assert results[1]["id"] == "1234x5679"
83+
assert results[1]["message"] is None
84+
85+
86+
@respx.mock
87+
async def test_bulk_create_parsed_partial_failure(configured_client: None) -> None:
88+
"""Test that bulk_create_parsed returns parsed results on partial failure."""
89+
mock_response_text = '{"status":"success","id":"1234x5678"}\n{"status":"error","message":"Invalid field value"}'
90+
respx.post("https://example.com/customer/bulk").mock(
91+
return_value=httpx.Response(200, text=mock_response_text, headers={"content-type": "text/plain"})
92+
)
93+
94+
async with raw_client.RawClient() as client:
95+
results = await client.bulk_create_parsed(
96+
typename="customer",
97+
data=[{"name": "Alice"}, {"name": ""}],
98+
)
99+
100+
assert len(results) == 2
101+
assert results[0]["status"] == "success"
102+
assert results[0]["id"] == "1234x5678"
103+
assert results[0]["message"] is None
104+
assert results[1]["status"] == "error"
105+
assert results[1]["id"] is None
106+
assert results[1]["message"] == "Invalid field value"
107+
108+
63109
@respx.mock
64110
async def test_find_with_parameters(configured_client: None) -> None:
65111
"""Test that find passes optional parameters correctly."""

0 commit comments

Comments
 (0)