Skip to content

Commit 1daedd0

Browse files
committed
sync readme with current library capabilities
1 parent 11580ba commit 1daedd0

1 file changed

Lines changed: 359 additions & 5 deletions

File tree

README.md

Lines changed: 359 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,372 @@
22
# bubble-data-api-client
33

44
[![Downloads](https://static.pepy.tech/badge/bubble-data-api-client/month)](https://pepy.tech/project/bubble-data-api-client)
5+
[![Python Version](https://img.shields.io/pypi/pyversions/bubble-data-api-client)](https://pypi.org/project/bubble-data-api-client/)
56

6-
**Async-first** Python client for the Bubble Data API, offering an **synchronous** and **asynchronous** interfaces.
7+
A fast, async Python client for the [Bubble Data API](https://manual.bubble.io/core-resources/api/the-bubble-api/the-data-api) with a Pydantic-based ORM and connection pooling.
8+
9+
## Why use this?
10+
11+
**If you're integrating Python with a Bubble app**, this library handles the boilerplate so you can focus on your logic.
12+
13+
**Common use cases:**
14+
- Syncing data between Bubble and external systems
15+
- Data migrations and bulk imports
16+
- Backend scripts and automation
17+
- Reporting that pulls from Bubble's database
18+
19+
### Clean, simple interface
20+
21+
```python
22+
# create
23+
user = await User.create(name="Ada", email="ada@example.com")
24+
25+
# retrieve
26+
user = await User.get(uid)
27+
28+
# query
29+
users = await User.find(constraints=[
30+
constraint("status", ConstraintTypes.EQUALS, "active")
31+
])
32+
33+
# update
34+
user.name = "Ada Lovelace"
35+
await user.save()
36+
37+
# delete
38+
await user.delete()
39+
40+
# check existence
41+
if await User.exists(uid):
42+
print("User exists")
43+
44+
# count
45+
active_count = await User.count(constraints=[
46+
constraint("status", ConstraintTypes.EQUALS, "active")
47+
])
48+
```
49+
50+
### IDE support and type checking
51+
52+
Models provide autocomplete and catch errors before runtime:
53+
54+
```python
55+
class User(BubbleBaseModel, typename="user"):
56+
name: str
57+
email: str
58+
age: int
59+
60+
user = await User.get(uid)
61+
user.name # IDE autocomplete works
62+
user.nme # Typo caught by pyright/mypy
63+
```
64+
65+
Works with pyright, mypy, and IDE type checkers.
66+
67+
### Validation catches bad data early
68+
69+
Pydantic validates data when models are created:
70+
71+
```python
72+
# Type mismatch caught immediately
73+
user = User(_id="123x456", name="Ada", email="ada@example.com", age="twenty-five")
74+
# ValidationError: Input should be a valid integer
75+
76+
# Invalid Bubble UID caught at the model level
77+
class Order(BubbleBaseModel, typename="order"):
78+
customer: BubbleUID
79+
80+
order = Order(_id="123x456", customer="not-a-valid-uid")
81+
# ValidationError: invalid Bubble UID format: not-a-valid-uid
82+
```
83+
84+
### Bubble-specific handling
85+
86+
The library handles Bubble's API quirks automatically:
87+
- **Field mapping**: Bubble's `_id` field maps to `uid` on your models
88+
- **Response parsing**: Extracts data from Bubble's nested `{"response": {"results": [...]}}` structure
89+
- **Constraint format**: Builds the JSON constraint format Bubble expects
90+
91+
### Duplicate handling
92+
93+
Bubble doesn't enforce unique constraints, so duplicates can occur. The `create_or_update` method provides strategies to handle this:
94+
95+
```python
96+
# If duplicates exist, keep the oldest and delete the rest
97+
user, created = await User.create_or_update(
98+
match={"external_id": "ext-123"},
99+
data={"name": "Canonical Name"},
100+
on_multiple=OnMultiple.DEDUPE_OLDEST,
101+
)
102+
```
103+
104+
### Connection reuse
105+
106+
HTTP connections are pooled per event loop, avoiding reconnection overhead when making multiple requests
7107

8108
## Features
9109

10-
- **Async-first design**: Built around `asyncio` for maximum performance and concurrency.
11-
- **Dual API support**: Use the client in both sync and async contexts with ease.
12-
- **Type hints** and **data validation** for robust integration.
13-
- Simple, intuitive method names matching Bubble's endpoints.
110+
- **Async-first:** built on `httpx` with HTTP/2
111+
- **Pydantic ORM:** define models once, get validation and autocomplete
112+
- **Connection pooling:** automatic per-event-loop client reuse
113+
- **Rich query constraints:** pythonic filtering using Bubble's constraint system
114+
- **Upsert with duplicate handling:** `create_or_update` with configurable strategies
115+
- **Configurable retries:** plug in your own retry policy via `tenacity`
116+
- **UID validation:** catch invalid Bubble IDs at the model level
14117

15118
## Installation
16119

17120
```bash
18121
pip install bubble-data-api-client
19122
```
123+
124+
Requires Python 3.13+.
125+
126+
## Quick Start
127+
128+
### Configuration
129+
130+
```python
131+
from bubble_data_api_client import configure
132+
133+
configure(
134+
data_api_root_url="https://your-app.bubbleapps.io/api/1.1/obj",
135+
api_key="your-api-key",
136+
)
137+
```
138+
139+
Or use a dynamic provider for secrets management:
140+
141+
```python
142+
import os
143+
from bubble_data_api_client import set_config_provider, BubbleConfig
144+
145+
def get_config() -> BubbleConfig:
146+
return BubbleConfig(
147+
data_api_root_url=os.environ["BUBBLE_API_URL"],
148+
api_key=os.environ["BUBBLE_API_KEY"],
149+
)
150+
151+
set_config_provider(get_config)
152+
```
153+
154+
### Using the ORM
155+
156+
Define typed models with validation:
157+
158+
```python
159+
from bubble_data_api_client import BubbleBaseModel, BubbleUID, OptionalBubbleUID
160+
161+
class User(BubbleBaseModel, typename="user"):
162+
name: str
163+
email: str
164+
company: OptionalBubbleUID = None # linked Bubble record
165+
166+
class Company(BubbleBaseModel, typename="company"):
167+
name: str
168+
industry: str
169+
```
170+
171+
Then use them:
172+
173+
```python
174+
# create
175+
user = await User.create(name="Ada Lovelace", email="ada@example.com")
176+
177+
# retrieve
178+
user = await User.get("1234567890x1234567890")
179+
180+
# query with constraints
181+
from bubble_data_api_client import constraint, ConstraintTypes
182+
183+
active_users = await User.find(constraints=[
184+
constraint("status", ConstraintTypes.EQUALS, "active"),
185+
constraint("age", ConstraintTypes.GREATER_THAN, 18),
186+
])
187+
188+
# update
189+
user.name = "Ada L."
190+
await user.save()
191+
192+
# delete
193+
await user.delete()
194+
```
195+
196+
## Smart Upserts
197+
198+
The `create_or_update` method handles the common "upsert" pattern with configurable strategies for handling duplicates:
199+
200+
```python
201+
from bubble_data_api_client import OnMultiple
202+
203+
# basic upsert - match by external_id, create if not found
204+
user, created = await User.create_or_update(
205+
match={"external_id": "ext-123"},
206+
data={"name": "Updated Name", "email": "new@example.com"},
207+
on_multiple=OnMultiple.ERROR,
208+
)
209+
# returns (User, bool) - the model instance and whether it was created
210+
```
211+
212+
### Duplicate Handling Strategies
213+
214+
Since Bubble doesn't enforce unique constraints, duplicates can occur. Choose how to handle them:
215+
216+
| Strategy | Behavior |
217+
|----------|----------|
218+
| `OnMultiple.ERROR` | Raise `MultipleMatchesError` (fail-fast) |
219+
| `OnMultiple.UPDATE_FIRST` | Update first match (arbitrary order) |
220+
| `OnMultiple.UPDATE_ALL` | Update all matches concurrently |
221+
| `OnMultiple.DEDUPE_OLDEST` | Keep oldest record, delete others, then update |
222+
| `OnMultiple.DEDUPE_NEWEST` | Keep newest record, delete others, then update |
223+
224+
```python
225+
# auto-deduplicate, keeping the oldest record
226+
user, created = await User.create_or_update(
227+
match={"external_id": "ext-123"},
228+
data={"name": "Canonical Name"},
229+
on_multiple=OnMultiple.DEDUPE_OLDEST,
230+
)
231+
```
232+
233+
## Constraints
234+
235+
Build type-safe queries using Bubble's constraint system:
236+
237+
```python
238+
from bubble_data_api_client import constraint, ConstraintTypes
239+
240+
constraints = [
241+
constraint("status", ConstraintTypes.EQUALS, "active"),
242+
constraint("age", ConstraintTypes.GREATER_THAN, 21),
243+
constraint("tags", ConstraintTypes.CONTAINS, "premium"),
244+
constraint("email", ConstraintTypes.IS_NOT_EMPTY),
245+
constraint("category", ConstraintTypes.IN, ["A", "B", "C"]),
246+
]
247+
248+
results = await User.find(constraints=constraints)
249+
```
250+
251+
Available constraint types: `EQUALS`, `NOT_EQUAL`, `IS_EMPTY` (any field), `IS_NOT_EMPTY` (any field), `TEXT_CONTAINS`, `NOT_TEXT_CONTAINS`, `GREATER_THAN`, `LESS_THAN`, `IN`, `NOT_IN`, `CONTAINS`, `NOT_CONTAINS`, `EMPTY` (list fields), `NOT_EMPTY` (list fields), `GEOGRAPHIC_SEARCH`.
252+
253+
## Type-Safe Bubble UIDs
254+
255+
Validate Bubble record IDs at the type level:
256+
257+
```python
258+
from bubble_data_api_client import BubbleBaseModel, BubbleUID, OptionalBubbleUID, OptionalBubbleUIDs
259+
260+
class Order(BubbleBaseModel, typename="order"):
261+
customer: BubbleUID # required, validated
262+
referrer: OptionalBubbleUID = None # optional, coerces invalid to None
263+
items: OptionalBubbleUIDs = None # list of UIDs, filters invalid
264+
265+
# validation helpers
266+
from bubble_data_api_client import is_bubble_uid, filter_bubble_uids
267+
268+
is_bubble_uid("1234567890x1234567890") # True
269+
is_bubble_uid("invalid") # False
270+
271+
filter_bubble_uids(["1661531100253x688916634279608300", "invalid", None]) # ["1661531100253x688916634279608300"]
272+
```
273+
274+
## Connection Pooling
275+
276+
Clients are automatically pooled per event loop. For explicit lifecycle control:
277+
278+
```python
279+
from bubble_data_api_client import client_scope, close_clients
280+
281+
# option 1: context manager (auto-closes on exit)
282+
async with client_scope():
283+
await User.create(name="Test", email="test@example.com")
284+
285+
# option 2: manual cleanup
286+
await close_clients()
287+
```
288+
289+
## Retry Configuration
290+
291+
Plug in custom retry policies using `tenacity`:
292+
293+
```python
294+
import httpx
295+
import tenacity
296+
from bubble_data_api_client import configure
297+
298+
retry_policy = tenacity.AsyncRetrying(
299+
wait=tenacity.wait_exponential(multiplier=1, min=1, max=10),
300+
stop=tenacity.stop_after_attempt(3),
301+
retry=tenacity.retry_if_exception_type(httpx.TimeoutException),
302+
)
303+
304+
configure(
305+
data_api_root_url="https://your-app.bubbleapps.io/api/1.1/obj",
306+
api_key="your-api-key",
307+
retry=retry_policy,
308+
)
309+
```
310+
311+
## Usage in Sync Contexts
312+
313+
This library is async-only, but you can use it in sync code:
314+
315+
```python
316+
import asyncio
317+
from bubble_data_api_client import BubbleBaseModel, constraint, ConstraintTypes
318+
319+
class User(BubbleBaseModel, typename="user"):
320+
name: str
321+
email: str
322+
early_access_enabled: bool = False
323+
324+
# simple scripts
325+
user = asyncio.run(User.get("1234567890x1234567890"))
326+
327+
# or wrap multiple operations
328+
async def main():
329+
constraints = [
330+
constraint("is_verified", ConstraintTypes.EQUALS, True),
331+
constraint("account_type", ConstraintTypes.EQUALS, "premium"),
332+
]
333+
users = await User.find(constraints=constraints)
334+
for user in users:
335+
user.early_access_enabled = True
336+
await user.save()
337+
338+
asyncio.run(main())
339+
```
340+
341+
## Error Handling
342+
343+
```python
344+
from bubble_data_api_client import OnMultiple
345+
from bubble_data_api_client.exceptions import (
346+
BubbleError, # base exception
347+
BubbleHttpError, # HTTP errors
348+
BubbleUnauthorizedError, # 401/403 responses
349+
MultipleMatchesError, # create_or_update found duplicates (with on_multiple=ERROR)
350+
PartialFailureError, # some batch operations failed
351+
InvalidBubbleUIDError, # invalid UID format
352+
ConfigurationError, # missing configuration
353+
)
354+
355+
# get() returns None if not found
356+
user = await User.get("1661531100253x688916634279608300")
357+
if user is None:
358+
print("User not found")
359+
360+
# create_or_update raises MultipleMatchesError with on_multiple=ERROR
361+
try:
362+
user, created = await User.create_or_update(
363+
match={"external_id": "ext-123"},
364+
data={"name": "Test"},
365+
on_multiple=OnMultiple.ERROR,
366+
)
367+
except MultipleMatchesError as e:
368+
print(f"Found {e.count} duplicates for {e.match}")
369+
```
370+
371+
## License
372+
373+
MIT

0 commit comments

Comments
 (0)