From fd91a26a384ed471967089c78c68dee23d27d3eb Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 16 Jul 2026 22:48:29 +0800 Subject: [PATCH 1/2] refactor(models): route Bubble/Composer props through raw_access helpers --- models/conversation.py | 245 +++++++++++++++--------------------- models/raw_access.py | 76 ++++++++++- tests/test_raw_accessors.py | 41 +++++- 3 files changed, 210 insertions(+), 152 deletions(-) diff --git a/models/conversation.py b/models/conversation.py index 3c45d99..a61ae71 100644 --- a/models/conversation.py +++ b/models/conversation.py @@ -23,6 +23,13 @@ require_non_empty_str_field, require_type, ) +from models.raw_access import ( + _optional_dict_absent_ok, + _optional_dict_default_empty, + _optional_list_absent_none, + _optional_list_absent_ok, + _optional_number_absent_ok, +) _logger = logging.getLogger(__name__) @@ -146,61 +153,39 @@ def cursor_storage_payload(self) -> dict[str, Any]: @property def newly_created_files(self) -> list[Any]: - value = self._raw.get("newlyCreatedFiles") - if value is None: - return [] - if not isinstance(value, list): - _logger.warning( - "Schema drift in Composer %s: invalid type for newlyCreatedFiles (expected list, got %s)", - self.composer_id, - type(value).__name__, - ) - return [] - return value + return _optional_list_absent_ok( + self._raw, + "newlyCreatedFiles", + model="Composer", + entity_id=self.composer_id, + ) @property def code_block_data(self) -> dict[str, Any] | None: - value = self._raw.get("codeBlockData") - if value is None: - return None - if not isinstance(value, dict): - _logger.warning( - "Schema drift in Composer %s: invalid type for codeBlockData (expected dict, got %s)", - self.composer_id, - type(value).__name__, - ) - return None - return value + return _optional_dict_absent_ok( + self._raw, + "codeBlockData", + model="Composer", + entity_id=self.composer_id, + ) @property def usage_data(self) -> dict[str, Any]: """Composer cost rollup; empty dict when absent (common).""" - value = self._raw.get("usageData") - if value is None: - return {} - if not isinstance(value, dict): - suffix = f" {self.composer_id}" if self.composer_id else "" - _logger.warning( - "Schema drift in Composer%s: invalid type for usageData (expected dict, got %s)", - suffix, - type(value).__name__, - ) - return {} - return value + return _optional_dict_default_empty( + self._raw, + "usageData", + model="Composer", + entity_id=self.composer_id, + ) def _optional_counter(self, key: str) -> int | float: - value = self._raw.get(key, 0) - if isinstance(value, bool) or not isinstance(value, (int, float)): - if key in self._raw: - suffix = f" {self.composer_id}" if self.composer_id else "" - _logger.warning( - "Schema drift in Composer%s: invalid type for %s (expected number, got %s)", - suffix, - key, - type(value).__name__, - ) - return 0 - return cast(int | float, value) + return _optional_number_absent_ok( + self._raw, + key, + model="Composer", + entity_id=self.composer_id, + ) @property def total_lines_added(self) -> int | float: @@ -307,32 +292,25 @@ def text(self) -> str | None: @property def metadata(self) -> BubbleMetadataDict: - value = self._raw.get("metadata") - if value is None: - return {} - if not isinstance(value, dict): - _logger.warning( - "Schema drift in Bubble %s: invalid type for metadata (expected dict, got %s)", - self.bubble_id, - type(value).__name__, - ) - return {} - return cast(BubbleMetadataDict, value) + return cast( + BubbleMetadataDict, + _optional_dict_default_empty( + self._raw, + "metadata", + model="Bubble", + entity_id=self.bubble_id, + ), + ) @property def relevant_files(self) -> list[str]: - value = self._raw.get("relevantFiles") - if value is None: - return [] - if not isinstance(value, list): - _logger.warning( - "Schema drift in Bubble %s: invalid type for relevantFiles (expected list, got %s)", - self.bubble_id, - type(value).__name__, - ) - return [] return _filter_str_list_elements( - value, + _optional_list_absent_ok( + self._raw, + "relevantFiles", + model="Bubble", + entity_id=self.bubble_id, + ), model="Bubble", record_id=self.bubble_id, field="relevantFiles", @@ -340,18 +318,13 @@ def relevant_files(self) -> list[str]: @property def attached_file_code_chunks_uris(self) -> list[FileUriDict]: - value = self._raw.get("attachedFileCodeChunksUris") - if value is None: - return [] - if not isinstance(value, list): - _logger.warning( - "Schema drift in Bubble %s: invalid type for attachedFileCodeChunksUris (expected list, got %s)", - self.bubble_id, - type(value).__name__, - ) - return [] return _filter_dict_list_elements( - value, + _optional_list_absent_ok( + self._raw, + "attachedFileCodeChunksUris", + model="Bubble", + entity_id=self.bubble_id, + ), model="Bubble", record_id=self.bubble_id, field="attachedFileCodeChunksUris", @@ -359,59 +332,47 @@ def attached_file_code_chunks_uris(self) -> list[FileUriDict]: @property def context(self) -> BubbleContextDict: - value = self._raw.get("context") - if value is None: - return {} - if not isinstance(value, dict): - _logger.warning( - "Schema drift in Bubble %s: invalid type for context (expected dict, got %s)", - self.bubble_id, - type(value).__name__, - ) - return {} - return cast(BubbleContextDict, value) + return cast( + BubbleContextDict, + _optional_dict_default_empty( + self._raw, + "context", + model="Bubble", + entity_id=self.bubble_id, + ), + ) @property def token_count(self) -> TokenCountDict | None: - value = self._raw.get("tokenCount") - if value is None: - return None - if not isinstance(value, dict): - _logger.warning( - "Schema drift in Bubble %s: invalid type for tokenCount (expected dict, got %s)", - self.bubble_id, - type(value).__name__, - ) - return None - return cast(TokenCountDict, value) + value = _optional_dict_absent_ok( + self._raw, + "tokenCount", + model="Bubble", + entity_id=self.bubble_id, + ) + return cast(TokenCountDict, value) if value is not None else None @property def tool_former_data(self) -> ToolFormerDataDict | None: - value = self._raw.get("toolFormerData") - if value is None: - return None - if not isinstance(value, dict): - _logger.warning( - "Schema drift in Bubble %s: invalid type for toolFormerData (expected dict, got %s)", - self.bubble_id, - type(value).__name__, - ) - return None - return cast(ToolFormerDataDict, value) + value = _optional_dict_absent_ok( + self._raw, + "toolFormerData", + model="Bubble", + entity_id=self.bubble_id, + ) + return cast(ToolFormerDataDict, value) if value is not None else None @property def model_info(self) -> ModelInfoDict: - value = self._raw.get("modelInfo") - if value is None: - return {} - if not isinstance(value, dict): - _logger.warning( - "Schema drift in Bubble %s: invalid type for modelInfo (expected dict, got %s)", - self.bubble_id, - type(value).__name__, - ) - return {} - return cast(ModelInfoDict, value) + return cast( + ModelInfoDict, + _optional_dict_default_empty( + self._raw, + "modelInfo", + model="Bubble", + entity_id=self.bubble_id, + ), + ) @property def thinking(self) -> str | ThinkingDict | None: @@ -443,31 +404,25 @@ def thinking_duration_ms(self) -> int | float | None: @property def context_window_status_at_creation(self) -> ContextWindowStatusDict: - value = self._raw.get("contextWindowStatusAtCreation") - if value is None: - return {} - if not isinstance(value, dict): - _logger.warning( - "Schema drift in Bubble %s: invalid type for contextWindowStatusAtCreation (expected dict, got %s)", - self.bubble_id, - type(value).__name__, - ) - return {} - return cast(ContextWindowStatusDict, value) + return cast( + ContextWindowStatusDict, + _optional_dict_default_empty( + self._raw, + "contextWindowStatusAtCreation", + model="Bubble", + entity_id=self.bubble_id, + ), + ) @property def tool_results(self) -> list[ToolResultEntry] | None: - value = self._raw.get("toolResults") - if value is None: - return None - if not isinstance(value, list): - _logger.warning( - "Schema drift in Bubble %s: invalid type for toolResults (expected list, got %s)", - self.bubble_id, - type(value).__name__, - ) - return None - return cast(list[ToolResultEntry], value) + value = _optional_list_absent_none( + self._raw, + "toolResults", + model="Bubble", + entity_id=self.bubble_id, + ) + return cast(list[ToolResultEntry], value) if value is not None else None def bubble_timestamp_ms(self) -> int | float | None: """``createdAt`` or ``timestamp`` in milliseconds when present.""" diff --git a/models/raw_access.py b/models/raw_access.py index 0700385..f65aa59 100644 --- a/models/raw_access.py +++ b/models/raw_access.py @@ -154,6 +154,68 @@ def _optional_dict_absent_ok( return value +def _optional_dict_default_empty( + raw: dict[str, Any], + key: str, + *, + model: str, + entity_id: str = "", +) -> dict[str, Any]: + """Like :func:`_optional_dict_absent_ok` but returns ``{}`` when absent.""" + value = _optional_dict_absent_ok( + raw, key, model=model, entity_id=entity_id + ) + return value if value is not None else {} + + +def _optional_list_absent_none( + raw: dict[str, Any], + key: str, + *, + model: str, + entity_id: str = "", +) -> list[Any] | None: + """List field often omitted; warn only on wrong type; ``None`` when absent or drifted.""" + value = raw.get(key) + if value is None: + return None + if not isinstance(value, list): + suffix = f" {entity_id}" if entity_id else "" + _logger.warning( + "Schema drift in %s%s: invalid type for %s (expected list, got %s)", + model, + suffix, + key, + type(value).__name__, + ) + return None + return value + + +def _optional_number_absent_ok( + raw: dict[str, Any], + key: str, + *, + model: str, + entity_id: str = "", + default: int | float = 0, +) -> int | float: + """Numeric counter field; ``default`` when absent; warn only on wrong type when present.""" + value = raw.get(key, default) + if isinstance(value, bool) or not isinstance(value, (int, float)): + if key in raw: + suffix = f" {entity_id}" if entity_id else "" + _logger.warning( + "Schema drift in %s%s: invalid type for %s (expected number, got %s)", + model, + suffix, + key, + type(value).__name__, + ) + return default + return cast(int | float, value) + + def optional_raw_str( raw: dict[str, Any], key: str, @@ -315,10 +377,12 @@ def bubble_context(bubble: Bubble | dict[str, Any], bubble_id: str = "") -> Bubb if isinstance(bubble, Bubble): return bubble.context - ctx = _optional_dict_absent_ok( - bubble, - "context", - model="Bubble", - entity_id=bubble_id, + return cast( + BubbleContextDict, + _optional_dict_default_empty( + bubble, + "context", + model="Bubble", + entity_id=bubble_id, + ), ) - return cast(BubbleContextDict, ctx if ctx is not None else {}) diff --git a/tests/test_raw_accessors.py b/tests/test_raw_accessors.py index 620f441..321d8ab 100644 --- a/tests/test_raw_accessors.py +++ b/tests/test_raw_accessors.py @@ -40,10 +40,43 @@ def test_composer_newly_created_files_warns_on_wrong_type(self) -> None: {**GOOD_COMPOSER_RAW, "newlyCreatedFiles": "not-a-list"}, composer_id="cid-bad", ) - with self.assertLogs("models.conversation", level="WARNING") as logs: + with self.assertLogs("models.raw_access", level="WARNING") as logs: self.assertEqual(bad.newly_created_files, []) self.assertTrue(any("newlyCreatedFiles" in m for m in logs.output), logs.output) + def test_composer_code_block_data_warns_on_wrong_type(self) -> None: + bad = Composer.from_dict( + {**GOOD_COMPOSER_RAW, "codeBlockData": "not-a-dict"}, + composer_id="cid-bad", + ) + with self.assertLogs("models.raw_access", level="WARNING") as logs: + self.assertIsNone(bad.code_block_data) + self.assertTrue(any("codeBlockData" in m for m in logs.output), logs.output) + + def test_bubble_metadata_warns_on_wrong_type(self) -> None: + bubble = Bubble.from_dict({"metadata": "not-a-dict"}, bubble_id="b-meta") + with self.assertLogs("models.raw_access", level="WARNING") as logs: + self.assertEqual(bubble.metadata, {}) + self.assertTrue(any("metadata" in m for m in logs.output), logs.output) + + def test_bubble_token_count_warns_on_wrong_type(self) -> None: + bubble = Bubble.from_dict({"tokenCount": "not-a-dict"}, bubble_id="b-tok") + with self.assertLogs("models.raw_access", level="WARNING") as logs: + self.assertIsNone(bubble.token_count) + self.assertTrue(any("tokenCount" in m for m in logs.output), logs.output) + + def test_bubble_tool_results_warns_on_wrong_type(self) -> None: + bubble = Bubble.from_dict({"toolResults": "not-a-list"}, bubble_id="b-tr") + with self.assertLogs("models.raw_access", level="WARNING") as logs: + self.assertIsNone(bubble.tool_results) + self.assertTrue(any("toolResults" in m for m in logs.output), logs.output) + + def test_bubble_relevant_files_warns_on_wrong_list_type(self) -> None: + bubble = Bubble.from_dict({"relevantFiles": "not-a-list"}, bubble_id="b-rel-bad") + with self.assertLogs("models.raw_access", level="WARNING") as logs: + self.assertEqual(bubble.relevant_files, []) + self.assertTrue(any("relevantFiles" in m for m in logs.output), logs.output) + def test_bubble_relevant_files_empty_when_key_missing(self) -> None: bubble = Bubble.from_dict({"type": "user", "text": "hi"}, bubble_id="b-1") with self.assertNoLogs("models.conversation", level="WARNING"): @@ -177,6 +210,12 @@ def test_dict_bridge_relevant_files_skips_non_str_elements(self) -> None: self.assertEqual(bubble.relevant_files, bubble_relevant_files(bubble, "b-bridge")) self.assertTrue(any("relevantFiles" in m for m in logs.output), logs.output) + def test_dict_bridge_relevant_files_warns_on_wrong_list_type(self) -> None: + raw = {"relevantFiles": "not-a-list"} + with self.assertLogs("models.raw_access", level="WARNING") as logs: + self.assertEqual(bubble_relevant_files(raw, "b-bridge"), []) + self.assertTrue(any("relevantFiles" in m for m in logs.output), logs.output) + def test_dict_bridge_bubble_context_matches_bubble_property(self) -> None: raw = {"context": {"fileSelections": [{"uri": {"path": "/a.py"}}]}} bubble = Bubble.from_dict(raw, bubble_id="b-bridge-ctx") From b7158478572304b04c4a260f735e9d4dff64c982 Mon Sep 17 00:00:00 2001 From: chen Date: Fri, 17 Jul 2026 02:26:34 +0800 Subject: [PATCH 2/2] docs(models): note why thinking accessors stay inline --- models/conversation.py | 2 ++ models/raw_access.py | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/models/conversation.py b/models/conversation.py index a61ae71..527d4d8 100644 --- a/models/conversation.py +++ b/models/conversation.py @@ -376,6 +376,7 @@ def model_info(self) -> ModelInfoDict: @property def thinking(self) -> str | ThinkingDict | None: + # Inline: accepts str | dict; no raw_access helper for that union. value = self._raw.get("thinking") if value is None: return None @@ -390,6 +391,7 @@ def thinking(self) -> str | ThinkingDict | None: @property def thinking_duration_ms(self) -> int | float | None: + # Inline: absent -> None (not 0); bool guard before numeric check. value = self._raw.get("thinkingDurationMs") if value is None: return None diff --git a/models/raw_access.py b/models/raw_access.py index f65aa59..0781b07 100644 --- a/models/raw_access.py +++ b/models/raw_access.py @@ -1,4 +1,10 @@ -"""Optional-key reads from Cursor JSON blobs with schema-drift logging.""" +"""Optional-key reads from Cursor JSON blobs with schema-drift logging. + +Underscore-prefixed ``_optional_*`` helpers are package-internal shared +implementation used by ``conversation.py`` property accessors and dict-bridge +functions here. They are not re-exported from ``models``; the prefix marks +intra-package reuse, not single-module privacy. +""" from __future__ import annotations