Skip to content

Commit f3904e2

Browse files
refactor(models): tighten Bubble boundary with typed accessors
1 parent 347d771 commit f3904e2

10 files changed

Lines changed: 184 additions & 87 deletions

api/composers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ def get_composer(composer_id: str) -> tuple[Response, int] | Response:
201201
# whether it's absent or None, so the response shape
202202
# is identical regardless of which branch resolved
203203
# the composer (CodeRabbit on PR #30).
204-
payload = dict(local.raw)
204+
payload = local.cursor_storage_payload()
205205
payload["conversation"] = payload.get("conversation") or []
206206
return json_response(payload)
207207
except SchemaError as e:
@@ -240,7 +240,7 @@ def get_composer(composer_id: str) -> tuple[Response, int] | Response:
240240
type(e).__name__,
241241
)
242242
return json_response({"error": "Composer schema drift"}, 404)
243-
payload = dict(composer.raw)
243+
payload = composer.cursor_storage_payload()
244244
payload["conversation"] = payload.get("conversation") or []
245245
return json_response(payload)
246246
except (OSError, sqlite3.Error, json.JSONDecodeError, ValueError):

models/conversation.py

Lines changed: 73 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,17 @@
44
from dataclasses import dataclass, field
55
from typing import Any, cast
66

7+
from models.conversation_types import (
8+
BubbleContextDict,
9+
BubbleMetadataDict,
10+
ContextWindowStatusDict,
11+
FileUriDict,
12+
ModelInfoDict,
13+
ThinkingDict,
14+
TokenCountDict,
15+
ToolFormerDataDict,
16+
ToolResultEntry,
17+
)
718
from models.errors import SchemaError
819
from models.from_dict_validation import (
920
require_dict,
@@ -26,7 +37,7 @@ class Composer:
2637
name: str | None = None
2738
last_updated_at: Any = None
2839
model_config: dict[str, Any] = field(default_factory=dict)
29-
raw: dict[str, Any] = field(default_factory=dict)
40+
_raw: dict[str, Any] = field(default_factory=dict, repr=False)
3041

3142
@classmethod
3243
def from_dict(cls, raw: dict[str, Any], *, composer_id: str) -> "Composer":
@@ -79,12 +90,19 @@ def from_dict(cls, raw: dict[str, Any], *, composer_id: str) -> "Composer":
7990
name=raw.get("name"),
8091
last_updated_at=raw.get("lastUpdatedAt"),
8192
model_config=model_config,
82-
raw=raw,
93+
_raw=raw,
8394
)
8495

96+
def cursor_storage_payload(self) -> dict[str, Any]:
97+
"""Shallow copy of stored Cursor JSON for API passthrough.
98+
99+
Prefer typed accessors for field reads.
100+
"""
101+
return dict(self._raw)
102+
85103
@property
86104
def newly_created_files(self) -> list[Any]:
87-
value = self.raw.get("newlyCreatedFiles")
105+
value = self._raw.get("newlyCreatedFiles")
88106
if value is None:
89107
return []
90108
if not isinstance(value, list):
@@ -98,7 +116,7 @@ def newly_created_files(self) -> list[Any]:
98116

99117
@property
100118
def code_block_data(self) -> dict[str, Any] | None:
101-
value = self.raw.get("codeBlockData")
119+
value = self._raw.get("codeBlockData")
102120
if value is None:
103121
return None
104122
if not isinstance(value, dict):
@@ -113,7 +131,7 @@ def code_block_data(self) -> dict[str, Any] | None:
113131
@property
114132
def usage_data(self) -> dict[str, Any]:
115133
"""Composer cost rollup; empty dict when absent (common)."""
116-
value = self.raw.get("usageData")
134+
value = self._raw.get("usageData")
117135
if value is None:
118136
return {}
119137
if not isinstance(value, dict):
@@ -127,9 +145,9 @@ def usage_data(self) -> dict[str, Any]:
127145
return value
128146

129147
def _optional_counter(self, key: str) -> int | float:
130-
value = self.raw.get(key, 0)
148+
value = self._raw.get(key, 0)
131149
if isinstance(value, bool) or not isinstance(value, (int, float)):
132-
if key in self.raw:
150+
if key in self._raw:
133151
suffix = f" {self.composer_id}" if self.composer_id else ""
134152
_logger.warning(
135153
"Schema drift in Composer%s: invalid type for %s (expected number, got %s)",
@@ -172,7 +190,7 @@ class WorkspaceLocalComposer:
172190

173191
composer_id: str
174192
last_updated_at: Any = None
175-
raw: dict[str, Any] = field(default_factory=dict)
193+
_raw: dict[str, Any] = field(default_factory=dict, repr=False)
176194

177195
@classmethod
178196
def from_dict(cls, raw: dict[str, Any]) -> "WorkspaceLocalComposer":
@@ -194,9 +212,13 @@ def from_dict(cls, raw: dict[str, Any]) -> "WorkspaceLocalComposer":
194212
return cls(
195213
composer_id=composer_id,
196214
last_updated_at=raw.get("lastUpdatedAt"),
197-
raw=raw,
215+
_raw=raw,
198216
)
199217

218+
def cursor_storage_payload(self) -> dict[str, Any]:
219+
"""Shallow copy of stored Cursor JSON for API passthrough."""
220+
return dict(self._raw)
221+
200222

201223
@dataclass(frozen=True)
202224
class Bubble:
@@ -206,7 +228,7 @@ class Bubble:
206228
"""
207229

208230
bubble_id: str
209-
raw: dict[str, Any] = field(default_factory=dict)
231+
_raw: dict[str, Any] = field(default_factory=dict, repr=False)
210232

211233
@classmethod
212234
def from_dict(cls, raw: dict[str, Any], *, bubble_id: str) -> "Bubble":
@@ -224,17 +246,24 @@ def from_dict(cls, raw: dict[str, Any], *, bubble_id: str) -> "Bubble":
224246
"""
225247
raw = require_dict(raw, model="Bubble", field="bubble")
226248
require_non_empty_str(bubble_id, model="Bubble", field="bubbleId")
227-
return cls(bubble_id=bubble_id, raw=raw)
249+
return cls(bubble_id=bubble_id, _raw=raw)
250+
251+
def cursor_storage_payload(self) -> dict[str, Any]:
252+
"""Shallow copy of stored Cursor JSON for API passthrough.
253+
254+
Prefer typed accessors for field reads.
255+
"""
256+
return dict(self._raw)
228257

229258
@property
230259
def text(self) -> str | None:
231260
"""Plain ``text`` field; richText is handled by :func:`extract_text_from_bubble`."""
232-
value = self.raw.get("text")
261+
value = self._raw.get("text")
233262
return value if isinstance(value, str) else None
234263

235264
@property
236-
def metadata(self) -> dict[str, Any]:
237-
value = self.raw.get("metadata")
265+
def metadata(self) -> BubbleMetadataDict:
266+
value = self._raw.get("metadata")
238267
if value is None:
239268
return {}
240269
if not isinstance(value, dict):
@@ -244,11 +273,11 @@ def metadata(self) -> dict[str, Any]:
244273
type(value).__name__,
245274
)
246275
return {}
247-
return value
276+
return cast(BubbleMetadataDict, value)
248277

249278
@property
250-
def relevant_files(self) -> list[Any]:
251-
value = self.raw.get("relevantFiles")
279+
def relevant_files(self) -> list[str]:
280+
value = self._raw.get("relevantFiles")
252281
if value is None:
253282
return []
254283
if not isinstance(value, list):
@@ -258,11 +287,11 @@ def relevant_files(self) -> list[Any]:
258287
type(value).__name__,
259288
)
260289
return []
261-
return value
290+
return cast(list[str], value)
262291

263292
@property
264-
def attached_file_code_chunks_uris(self) -> list[Any]:
265-
value = self.raw.get("attachedFileCodeChunksUris")
293+
def attached_file_code_chunks_uris(self) -> list[FileUriDict]:
294+
value = self._raw.get("attachedFileCodeChunksUris")
266295
if value is None:
267296
return []
268297
if not isinstance(value, list):
@@ -272,11 +301,11 @@ def attached_file_code_chunks_uris(self) -> list[Any]:
272301
type(value).__name__,
273302
)
274303
return []
275-
return value
304+
return cast(list[FileUriDict], value)
276305

277306
@property
278-
def context(self) -> dict[str, Any]:
279-
value = self.raw.get("context")
307+
def context(self) -> BubbleContextDict:
308+
value = self._raw.get("context")
280309
if value is None:
281310
return {}
282311
if not isinstance(value, dict):
@@ -286,11 +315,11 @@ def context(self) -> dict[str, Any]:
286315
type(value).__name__,
287316
)
288317
return {}
289-
return value
318+
return cast(BubbleContextDict, value)
290319

291320
@property
292-
def token_count(self) -> dict[str, Any] | None:
293-
value = self.raw.get("tokenCount")
321+
def token_count(self) -> TokenCountDict | None:
322+
value = self._raw.get("tokenCount")
294323
if value is None:
295324
return None
296325
if not isinstance(value, dict):
@@ -300,11 +329,11 @@ def token_count(self) -> dict[str, Any] | None:
300329
type(value).__name__,
301330
)
302331
return None
303-
return value
332+
return cast(TokenCountDict, value)
304333

305334
@property
306-
def tool_former_data(self) -> dict[str, Any] | None:
307-
value = self.raw.get("toolFormerData")
335+
def tool_former_data(self) -> ToolFormerDataDict | None:
336+
value = self._raw.get("toolFormerData")
308337
if value is None:
309338
return None
310339
if not isinstance(value, dict):
@@ -314,11 +343,11 @@ def tool_former_data(self) -> dict[str, Any] | None:
314343
type(value).__name__,
315344
)
316345
return None
317-
return value
346+
return cast(ToolFormerDataDict, value)
318347

319348
@property
320-
def model_info(self) -> dict[str, Any]:
321-
value = self.raw.get("modelInfo")
349+
def model_info(self) -> ModelInfoDict:
350+
value = self._raw.get("modelInfo")
322351
if value is None:
323352
return {}
324353
if not isinstance(value, dict):
@@ -328,15 +357,15 @@ def model_info(self) -> dict[str, Any]:
328357
type(value).__name__,
329358
)
330359
return {}
331-
return value
360+
return cast(ModelInfoDict, value)
332361

333362
@property
334-
def thinking(self) -> str | dict[str, Any] | None:
335-
value = self.raw.get("thinking")
363+
def thinking(self) -> str | ThinkingDict | None:
364+
value = self._raw.get("thinking")
336365
if value is None:
337366
return None
338367
if isinstance(value, (str, dict)):
339-
return value
368+
return cast(str | ThinkingDict, value)
340369
_logger.warning(
341370
"Schema drift in Bubble %s: invalid type for thinking (expected str or dict, got %s)",
342371
self.bubble_id,
@@ -346,7 +375,7 @@ def thinking(self) -> str | dict[str, Any] | None:
346375

347376
@property
348377
def thinking_duration_ms(self) -> int | float | None:
349-
value = self.raw.get("thinkingDurationMs")
378+
value = self._raw.get("thinkingDurationMs")
350379
if value is None:
351380
return None
352381
if isinstance(value, bool) or not isinstance(value, (int, float)):
@@ -359,8 +388,8 @@ def thinking_duration_ms(self) -> int | float | None:
359388
return cast(int | float, value)
360389

361390
@property
362-
def context_window_status_at_creation(self) -> dict[str, Any]:
363-
value = self.raw.get("contextWindowStatusAtCreation")
391+
def context_window_status_at_creation(self) -> ContextWindowStatusDict:
392+
value = self._raw.get("contextWindowStatusAtCreation")
364393
if value is None:
365394
return {}
366395
if not isinstance(value, dict):
@@ -370,11 +399,11 @@ def context_window_status_at_creation(self) -> dict[str, Any]:
370399
type(value).__name__,
371400
)
372401
return {}
373-
return value
402+
return cast(ContextWindowStatusDict, value)
374403

375404
@property
376-
def tool_results(self) -> list[Any] | None:
377-
value = self.raw.get("toolResults")
405+
def tool_results(self) -> list[ToolResultEntry] | None:
406+
value = self._raw.get("toolResults")
378407
if value is None:
379408
return None
380409
if not isinstance(value, list):
@@ -384,12 +413,12 @@ def tool_results(self) -> list[Any] | None:
384413
type(value).__name__,
385414
)
386415
return None
387-
return value
416+
return cast(list[ToolResultEntry], value)
388417

389418
def bubble_timestamp_ms(self) -> int | float | None:
390419
"""``createdAt`` or ``timestamp`` in milliseconds when present."""
391420
for key in ("createdAt", "timestamp"):
392-
value = self.raw.get(key)
421+
value = self._raw.get(key)
393422
if isinstance(value, (int, float)) and not isinstance(value, bool):
394423
return value
395424
return None

models/conversation_types.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""Typed shapes for Cursor JSON fields on :class:`models.conversation.Bubble`."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, TypedDict
6+
7+
8+
class FileUriDict(TypedDict, total=False):
9+
"""URI object on attached-file or context entries."""
10+
11+
path: str
12+
13+
14+
class BubbleMetadataDict(TypedDict, total=False):
15+
"""Storage ``metadata`` blob on a bubble row."""
16+
17+
modelName: str
18+
19+
20+
class TokenCountDict(TypedDict, total=False):
21+
inputTokens: int
22+
outputTokens: int
23+
cachedTokens: int
24+
25+
26+
class ModelInfoDict(TypedDict, total=False):
27+
modelName: str
28+
29+
30+
class ContextWindowStatusDict(TypedDict, total=False):
31+
percentageRemainingFloat: float
32+
percentageRemaining: float
33+
tokensUsed: int
34+
tokenLimit: int
35+
36+
37+
class FileSelectionDict(TypedDict, total=False):
38+
uri: FileUriDict
39+
40+
41+
class BubbleContextDict(TypedDict, total=False):
42+
fileSelections: list[FileSelectionDict]
43+
44+
45+
class ToolFormerDataDict(TypedDict, total=False):
46+
name: str
47+
status: str
48+
params: str | dict[str, Any]
49+
rawArgs: str
50+
result: str
51+
52+
53+
class ThinkingDict(TypedDict, total=False):
54+
text: str
55+
56+
57+
class ToolResultEntry(TypedDict, total=False):
58+
"""One element of a bubble ``toolResults`` list."""
59+
60+
toolName: str
61+
result: str | dict[str, Any]

0 commit comments

Comments
 (0)