Skip to content

Commit eeb505c

Browse files
committed
add google-style docstrings for better IDE hints and API discoverability
1 parent ca277ca commit eeb505c

13 files changed

Lines changed: 151 additions & 21 deletions

File tree

pyproject.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,20 @@ target-version = "py313"
3838
line-length = 120
3939

4040
[tool.ruff.lint]
41-
extend-select = ["E", "F", "I", "FAST", "ASYNC", "TRY", "PERF", "UP", "FURB", "A", "B", "S", "C4", "PIE", "RUF", "TC"]
41+
extend-select = ["E", "F", "I", "FAST", "ASYNC", "TRY", "PERF", "UP", "FURB", "A", "B", "S", "C4", "PIE", "RUF", "TC", "D"]
4242
ignore = ["RUF022"]
4343

4444
[tool.ruff.lint.per-file-ignores]
45-
"src/tests/**/*.py" = ["S101"]
46-
"*/test_*.py" = ["S101"]
45+
"src/tests/**/*.py" = ["S101", "D"]
46+
"*/test_*.py" = ["S101", "D"]
4747

4848
[tool.ruff.lint.flake8-type-checking]
4949
runtime-evaluated-base-classes = ["pydantic.BaseModel"]
5050
runtime-evaluated-decorators = ["pydantic.validate_call"]
5151

52+
[tool.ruff.lint.pydocstyle]
53+
convention = "google"
54+
5255
[tool.pytest.ini_options]
5356
pythonpath = ["src"]
5457
asyncio_mode = "auto"

src/bubble_data_api_client/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
"""Python client for Bubble.io Data API with ORM-style models and async support.
2+
3+
This library provides two ways to interact with Bubble's Data API:
4+
- BubbleModel: ORM-style base class for defining typed data models with CRUD operations
5+
- RawClient: Low-level async client for direct API access
6+
7+
Quick start:
8+
1. Configure credentials: configure(data_api_root_url="...", api_key="...")
9+
2. Define a model: class User(BubbleModel, typename="user"): name: str
10+
3. Use CRUD operations: await User.create(name="Alice")
11+
"""
12+
113
from bubble_data_api_client.client.orm import BubbleModel
214
from bubble_data_api_client.client.raw_client import RawClient
315
from bubble_data_api_client.config import (
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Client layer providing ORM models and raw API access."""

src/bubble_data_api_client/client/client.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""High-level client with data validation and transformation."""
2+
13
from pydantic import BaseModel, Field
24

35
from bubble_data_api_client.client.raw_client import RawClient
@@ -19,27 +21,31 @@ class BubbleDataApiResponseBody(BaseModel):
1921

2022

2123
class CreateThingSuccessResponse(BaseModel):
24+
"""Response body returned when a thing is successfully created."""
25+
2226
status: str
2327
id: str
2428

2529

2630
class Bubble404ResponseBody(BaseModel):
31+
"""Body content of a Bubble 404 not found response."""
32+
2733
status: str
2834
message: str
2935

3036

3137
class Bubble404Response(BaseModel):
38+
"""Structured representation of a Bubble 404 response."""
39+
3240
status_code: int = Field(404, alias="statusCode")
3341
body: Bubble404ResponseBody
3442

3543

3644
class Client:
37-
"""
38-
Client layer focuses on providing a convenient interface.
39-
- CRUD operations
40-
- data validation
41-
- data transformation
42-
- error handling
45+
"""High-level Bubble API client with validation and error handling.
46+
47+
Provides CRUD operations with data validation and transformation.
48+
Consider using BubbleModel for ORM-style access instead.
4349
"""
4450

4551
_data_api_root_url: str
@@ -50,6 +56,12 @@ def __init__(
5056
self,
5157
data_api_root_url: str,
5258
api_key: str,
53-
):
59+
) -> None:
60+
"""Initialize client with Bubble API credentials.
61+
62+
Args:
63+
data_api_root_url: Base URL for the Bubble Data API.
64+
api_key: API key for authentication.
65+
"""
5466
self._data_api_root_url = data_api_root_url
5567
self._api_key = api_key

src/bubble_data_api_client/client/orm.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,19 @@
1+
"""ORM-style base class for Bubble data types.
2+
3+
Define models by subclassing BubbleModel with a typename parameter:
4+
5+
class User(BubbleModel, typename="user"):
6+
name: str
7+
email: str | None = None
8+
9+
Then use async CRUD operations:
10+
user = await User.create(name="Alice")
11+
user = await User.get("1234x5678")
12+
users = await User.find(constraints=[constraint("name", ConstraintType.EQUALS, "Alice")])
13+
await user.save()
14+
await user.delete()
15+
"""
16+
117
import http
218
import typing
319
from collections.abc import AsyncIterator
@@ -44,6 +60,7 @@ class BubbleModel(PydanticBaseModel):
4460
)
4561

4662
def __init_subclass__(cls, *, typename: str, **kwargs: typing.Any) -> None:
63+
"""Register the Bubble type name for this model subclass."""
4764
super().__init_subclass__(**kwargs)
4865
cls._typename = typename
4966

@@ -63,6 +80,14 @@ def _resolve_aliases(cls, data: dict[str, typing.Any]) -> dict[str, typing.Any]:
6380

6481
@classmethod
6582
async def create(cls, **data: typing.Any) -> typing.Self:
83+
"""Create a new thing in Bubble and return a model instance.
84+
85+
Args:
86+
**data: Field values using Python field names (not Bubble aliases).
87+
88+
Returns:
89+
A new model instance with the assigned Bubble UID.
90+
"""
6691
aliased_data = cls._resolve_aliases(data)
6792
async with _get_client() as client:
6893
response = await client.create(cls._typename, aliased_data)
@@ -94,6 +119,11 @@ async def get_many(cls, uids: list[str]) -> dict[str, typing.Self]:
94119
return {item.uid: item for item in items}
95120

96121
async def save(self) -> None:
122+
"""Persist all field changes to Bubble.
123+
124+
Saves all model fields except uid and server-managed fields
125+
(created_date, modified_date, slug).
126+
"""
97127
async with _get_client() as client:
98128
# exclude uid and server-managed fields
99129
data = self.model_dump(
@@ -112,6 +142,7 @@ async def update(cls, uid: str, **data: typing.Any) -> None:
112142
response.raise_for_status()
113143

114144
async def delete(self) -> None:
145+
"""Delete this thing from Bubble."""
115146
async with _get_client() as client:
116147
response = await client.delete(self._typename, self.uid)
117148
response.raise_for_status()
@@ -128,6 +159,20 @@ async def find(
128159
exclude_remaining: bool | None = None,
129160
additional_sort_fields: list[AdditionalSortField] | None = None,
130161
) -> list[typing.Self]:
162+
"""Search for things matching the given constraints.
163+
164+
Args:
165+
constraints: Filter conditions (use constraint() helper to build).
166+
cursor: Pagination offset (0-indexed).
167+
limit: Maximum results to return (default 100, max varies by plan).
168+
sort_field: Field name to sort by.
169+
descending: Sort in descending order if True.
170+
exclude_remaining: Skip counting remaining results for performance.
171+
additional_sort_fields: Secondary sort fields after the primary.
172+
173+
Returns:
174+
List of matching model instances.
175+
"""
131176
async with _get_client() as client:
132177
response = await client.find(
133178
cls._typename,

src/bubble_data_api_client/client/raw_client.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
"""Low-level async client for direct Bubble Data API access.
2+
3+
Use RawClient when you need direct control over API calls without ORM overhead.
4+
For most use cases, prefer BubbleModel which provides a higher-level interface.
5+
"""
6+
17
import asyncio
28
import http
39
import json
@@ -16,13 +22,14 @@
1622
# in addition to 'sort_field' and 'descending', it is possible to have
1723
# multiple additional sort fields
1824
class AdditionalSortField(typing.TypedDict):
25+
"""Secondary sort field for multi-field sorting in find queries."""
26+
1927
sort_field: str
2028
descending: bool
2129

2230

2331
class RawClient:
24-
"""
25-
Raw Client layer focuses on bubble.io API endpoints.
32+
"""Raw Client layer focuses on bubble.io API endpoints.
2633
2734
https://manual.bubble.io/core-resources/api/the-bubble-api/the-data-api/data-api-requests
2835
https://www.postman.com/bubbleapi/bubble/request/jigyk5v/
@@ -31,9 +38,10 @@ class RawClient:
3138
_transport: Transport
3239

3340
def __init__(self) -> None:
34-
pass
41+
"""Initialize the client (must be used as async context manager)."""
3542

3643
async def __aenter__(self) -> typing.Self:
44+
"""Enter async context and initialize the HTTP transport."""
3745
self._transport = Transport()
3846
await self._transport.__aenter__()
3947
return self
@@ -44,27 +52,34 @@ async def __aexit__(
4452
exc_val: BaseException | None,
4553
exc_tb: types.TracebackType | None,
4654
) -> None:
55+
"""Exit async context and close the HTTP transport."""
4756
await self._transport.__aexit__(exc_type, exc_val, exc_tb)
4857

4958
async def retrieve(self, typename: str, uid: str) -> httpx.Response:
59+
"""Fetch a single thing by its unique ID."""
5060
return await self._transport.get(f"/{typename}/{uid}")
5161

5262
async def create(self, typename: str, data: typing.Any) -> httpx.Response:
63+
"""Create a new thing with the given data."""
5364
return await self._transport.post(url=f"/{typename}", json=data)
5465

5566
async def bulk_create(self, typename: str, data: list[typing.Any]) -> httpx.Response:
67+
"""Create multiple things in a single request using newline-delimited JSON."""
5668
return await self._transport.post_text(
5769
url=f"/{typename}/bulk",
5870
content="\n".join(json.dumps(item) for item in data),
5971
)
6072

6173
async def delete(self, typename: str, uid: str) -> httpx.Response:
74+
"""Delete a thing by its unique ID."""
6275
return await self._transport.delete(f"/{typename}/{uid}")
6376

6477
async def update(self, typename: str, uid: str, data: typing.Any) -> httpx.Response:
78+
"""Partially update a thing with PATCH, only modifying specified fields."""
6579
return await self._transport.patch(f"/{typename}/{uid}", json=data)
6680

6781
async def replace(self, typename: str, uid: str, data: typing.Any) -> httpx.Response:
82+
"""Fully replace a thing's data with PUT, clearing unspecified fields."""
6883
return await self._transport.put(f"/{typename}/{uid}", json=data)
6984

7085
# https://manual.bubble.io/core-resources/api/the-bubble-api/the-data-api/data-api-requests#get-a-list-of-things
@@ -80,6 +95,7 @@ async def find(
8095
exclude_remaining: bool | None = None,
8196
additional_sort_fields: list[AdditionalSortField] | None = None,
8297
) -> httpx.Response:
98+
"""Search for things matching constraints with pagination and sorting."""
8399
params: dict[str, str] = {}
84100

85101
if constraints is not None:

src/bubble_data_api_client/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,14 @@
1+
"""Configuration management for Bubble Data API credentials and settings.
2+
3+
Configure the client before making API calls:
4+
5+
# Option 1: Static configuration
6+
configure(data_api_root_url="https://app.bubble.io/api/1.1/obj", api_key="...")
7+
8+
# Option 2: Dynamic configuration (e.g., for multi-tenant apps)
9+
set_config_provider(lambda: get_config_for_current_user())
10+
"""
11+
112
from collections.abc import Callable
213
from typing import NotRequired, TypedDict, TypeIs
314

@@ -10,6 +21,7 @@ class _NotSet:
1021
__slots__ = ()
1122

1223
def __repr__(self) -> str:
24+
"""Return string representation for debugging."""
1325
return "NOT_SET"
1426

1527

src/bubble_data_api_client/constraints.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
1+
"""Query constraint types for filtering Bubble Data API results.
2+
3+
Use the constraint() helper to build constraints for find() queries:
4+
5+
constraints = [
6+
constraint("name", ConstraintType.EQUALS, "Alice"),
7+
constraint("age", ConstraintType.GREATER_THAN, 18),
8+
]
9+
users = await User.find(constraints=constraints)
10+
"""
11+
112
import typing
213
from enum import StrEnum
314

415

5-
# all constraints are of the form:
616
class BaseConstraint(typing.TypedDict):
17+
"""Base structure for all constraint types."""
18+
719
key: str
820
constraint_type: str
921

1022

11-
# some constraints have a value, some do not
1223
class Constraint(BaseConstraint, total=False):
24+
"""A query constraint with optional value for filtering results."""
25+
1326
value: str
1427

1528

src/bubble_data_api_client/exceptions.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
"""Exception types for Bubble Data API errors."""
2+
3+
14
class BubbleError(Exception):
25
"""Base class for all exceptions raised by the library."""
36

@@ -6,6 +9,7 @@ class ConfigurationError(BubbleError):
69
"""Raised when required configuration is missing."""
710

811
def __init__(self, key: str) -> None:
12+
"""Create error for missing configuration key."""
913
super().__init__(f"{key} is not configured")
1014

1115

@@ -21,6 +25,7 @@ class InvalidBubbleUIDError(ValueError):
2125
"""Raised when a string is not a valid Bubble UID."""
2226

2327
def __init__(self, value: str) -> None:
28+
"""Create error for invalid UID format."""
2429
super().__init__(f"invalid Bubble UID format: {value}")
2530
self.value = value
2631

@@ -29,6 +34,7 @@ class UnknownFieldError(BubbleError):
2934
"""Raised when an unknown field name is passed to update()."""
3035

3136
def __init__(self, field_name: str) -> None:
37+
"""Create error for unknown field name."""
3238
super().__init__(f"unknown field: {field_name}")
3339
self.field_name = field_name
3440

@@ -37,6 +43,7 @@ class MultipleMatchesError(BubbleError):
3743
"""Raised when create_or_update finds multiple matches with on_multiple='error'."""
3844

3945
def __init__(self, typename: str, count: int, match: dict) -> None:
46+
"""Create error for unexpected multiple matches."""
4047
super().__init__(f"expected 0 or 1 matches for '{typename}', found {count} with match={match}")
4148
self.typename = typename
4249
self.count = count
@@ -47,6 +54,7 @@ class InvalidOnMultipleError(BubbleError):
4754
"""Raised when an invalid on_multiple strategy is provided."""
4855

4956
def __init__(self, value: str) -> None:
57+
"""Create error for invalid on_multiple strategy value."""
5058
super().__init__(f"invalid on_multiple strategy: '{value}'")
5159
self.value = value
5260

@@ -60,6 +68,7 @@ def __init__(
6068
succeeded: list[str],
6169
failed: list[tuple[str, BaseException]],
6270
) -> None:
71+
"""Create error with lists of succeeded and failed UIDs."""
6372
failed_count = len(failed)
6473
total = len(succeeded) + failed_count
6574
super().__init__(f"{operation}: {failed_count}/{total} operations failed")

src/bubble_data_api_client/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
"""Environment variable settings for Bubble API credentials."""
2+
13
import os
24

35
BUBBLE_DATA_API_ROOT_URL = os.getenv("BUBBLE_DATA_API_ROOT_URL")

0 commit comments

Comments
 (0)