|
2 | 2 | # bubble-data-api-client |
3 | 3 |
|
4 | 4 | [](https://pepy.tech/project/bubble-data-api-client) |
| 5 | +[](https://pypi.org/project/bubble-data-api-client/) |
5 | 6 |
|
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 |
7 | 107 |
|
8 | 108 | ## Features |
9 | 109 |
|
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 |
14 | 117 |
|
15 | 118 | ## Installation |
16 | 119 |
|
17 | 120 | ```bash |
18 | 121 | pip install bubble-data-api-client |
19 | 122 | ``` |
| 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