Skip to content

Commit adb75a4

Browse files
authored
Extract shared from_dict validation helpers for model classes (#70) (#80)
* initial implementation of issue#70 * fix: Missing keys are misclassified as invalid fields in required string-field helpers. * fix: review comments
1 parent faef80e commit adb75a4

6 files changed

Lines changed: 212 additions & 92 deletions

File tree

models/cli_session.py

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from typing import Any
55

66
from models.errors import SchemaError
7+
from models.from_dict_validation import require_dict, require_truthy
78

89

910
@dataclass(frozen=True)
@@ -16,15 +17,12 @@ class CliSessionMeta:
1617

1718
@classmethod
1819
def from_dict(cls, raw: dict[str, Any]) -> "CliSessionMeta":
19-
if not isinstance(raw, dict):
20-
raise SchemaError(
21-
"CliSessionMeta",
22-
"meta",
23-
hint=f"expected object, got {type(raw).__name__}",
24-
)
25-
latest = raw.get("latestRootBlobId")
26-
if not latest:
27-
raise SchemaError("CliSessionMeta", "latestRootBlobId")
20+
raw = require_dict(raw, model="CliSessionMeta", field="meta")
21+
latest = require_truthy(
22+
raw.get("latestRootBlobId"),
23+
model="CliSessionMeta",
24+
field="latestRootBlobId",
25+
)
2826
if not isinstance(latest, str):
2927
raise SchemaError(
3028
"CliSessionMeta",

models/conversation.py

Lines changed: 25 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@
44
from typing import Any
55

66
from models.errors import SchemaError
7+
from models.from_dict_validation import (
8+
require_dict,
9+
require_key,
10+
require_non_empty_str,
11+
require_non_empty_str_field,
12+
require_type,
13+
)
714

815

916
@dataclass(frozen=True)
@@ -20,22 +27,10 @@ class Composer:
2027

2128
@classmethod
2229
def from_dict(cls, raw: dict[str, Any], *, composer_id: str) -> "Composer":
23-
if not isinstance(raw, dict):
24-
raise SchemaError(
25-
"Composer",
26-
"composerData",
27-
hint=f"expected object, got {type(raw).__name__}",
28-
)
29-
if not isinstance(composer_id, str) or not composer_id:
30-
raise SchemaError(
31-
"Composer",
32-
"composerId",
33-
hint=f"expected non-empty str, got {type(composer_id).__name__}",
34-
)
35-
if "fullConversationHeadersOnly" not in raw:
36-
raise SchemaError("Composer", "fullConversationHeadersOnly")
37-
if "createdAt" not in raw:
38-
raise SchemaError("Composer", "createdAt")
30+
raw = require_dict(raw, model="Composer", field="composerData")
31+
require_non_empty_str(composer_id, model="Composer", field="composerId")
32+
require_key(raw, "fullConversationHeadersOnly", model="Composer")
33+
require_key(raw, "createdAt", model="Composer")
3934

4035
created_at = raw.get("createdAt")
4136
# Numeric-only on purpose: a 2026-05 scan of 17/17 live composers on
@@ -49,13 +44,14 @@ def from_dict(cls, raw: dict[str, Any], *, composer_id: str) -> "Composer":
4944
hint=f"expected timestamp number, got {type(created_at).__name__}",
5045
)
5146

52-
headers = raw.get("fullConversationHeadersOnly")
53-
if not isinstance(headers, list):
54-
raise SchemaError(
55-
"Composer",
56-
"fullConversationHeadersOnly",
57-
hint=f"expected list, got {type(headers).__name__}",
58-
)
47+
headers_value = raw.get("fullConversationHeadersOnly")
48+
headers = require_type(
49+
headers_value,
50+
list,
51+
model="Composer",
52+
field="fullConversationHeadersOnly",
53+
hint=f"expected list, got {type(headers_value).__name__}",
54+
)
5955

6056
model_config = raw.get("modelConfig") or {}
6157
if not isinstance(model_config, dict):
@@ -82,19 +78,10 @@ class WorkspaceLocalComposer:
8278

8379
@classmethod
8480
def from_dict(cls, raw: dict[str, Any]) -> "WorkspaceLocalComposer":
85-
if not isinstance(raw, dict):
86-
raise SchemaError(
87-
"WorkspaceLocalComposer",
88-
"composer",
89-
hint=f"expected object, got {type(raw).__name__}",
90-
)
91-
composer_id = raw.get("composerId")
92-
if not isinstance(composer_id, str) or not composer_id:
93-
raise SchemaError(
94-
"WorkspaceLocalComposer",
95-
"composerId",
96-
hint=f"expected non-empty str, got {type(composer_id).__name__}",
97-
)
81+
raw = require_dict(raw, model="WorkspaceLocalComposer", field="composer")
82+
composer_id = require_non_empty_str_field(
83+
raw, "composerId", model="WorkspaceLocalComposer"
84+
)
9885
return cls(
9986
composer_id=composer_id,
10087
last_updated_at=raw.get("lastUpdatedAt"),
@@ -111,16 +98,6 @@ class Bubble:
11198

11299
@classmethod
113100
def from_dict(cls, raw: dict[str, Any], *, bubble_id: str) -> "Bubble":
114-
if not isinstance(raw, dict):
115-
raise SchemaError(
116-
"Bubble",
117-
"bubble",
118-
hint=f"expected object, got {type(raw).__name__}",
119-
)
120-
if not isinstance(bubble_id, str) or not bubble_id:
121-
raise SchemaError(
122-
"Bubble",
123-
"bubbleId",
124-
hint=f"expected non-empty str, got {type(bubble_id).__name__}",
125-
)
101+
raw = require_dict(raw, model="Bubble", field="bubble")
102+
require_non_empty_str(bubble_id, model="Bubble", field="bubbleId")
126103
return cls(bubble_id=bubble_id, raw=raw)

models/export.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from dataclasses import dataclass, field
44
from typing import Any
55

6-
from models.errors import SchemaError
6+
from models.from_dict_validation import require_dict, require_non_empty_str_fields
77

88

99
@dataclass(frozen=True)
@@ -19,20 +19,12 @@ class ExportEntry:
1919

2020
@classmethod
2121
def from_dict(cls, raw: dict[str, Any]) -> "ExportEntry":
22-
if not isinstance(raw, dict):
23-
raise SchemaError(
24-
"ExportEntry",
25-
"entry",
26-
hint=f"expected object, got {type(raw).__name__}",
27-
)
28-
for required in ("log_id", "title", "workspace"):
29-
value = raw.get(required)
30-
if not isinstance(value, str) or value == "":
31-
raise SchemaError(
32-
"ExportEntry",
33-
required,
34-
hint=f"expected non-empty str, got {type(value).__name__}",
35-
)
22+
raw = require_dict(raw, model="ExportEntry", field="entry")
23+
require_non_empty_str_fields(
24+
raw,
25+
("log_id", "title", "workspace"),
26+
model="ExportEntry",
27+
)
3628
return cls(
3729
log_id=raw["log_id"],
3830
title=raw["title"],

models/from_dict_validation.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
from models.errors import SchemaError
6+
7+
8+
def require_dict(raw: Any, *, model: str, field: str) -> dict[str, Any]:
9+
"""Raise SchemaError when raw is not a dict; return raw for chaining."""
10+
if not isinstance(raw, dict):
11+
raise SchemaError(
12+
model,
13+
field,
14+
hint=f"expected object, got {type(raw).__name__}",
15+
)
16+
return raw
17+
18+
19+
def require_key(raw: dict[str, Any], key: str, *, model: str) -> None:
20+
"""Raise SchemaError when a required key is absent."""
21+
if key not in raw:
22+
raise SchemaError(model, key)
23+
24+
25+
def require_non_empty_str(value: Any, *, model: str, field: str) -> str:
26+
"""Validate a caller-supplied id (workspace_id, composer_id, bubble_id)."""
27+
if not isinstance(value, str) or not value:
28+
raise SchemaError(
29+
model,
30+
field,
31+
hint=f"expected non-empty str, got {type(value).__name__}",
32+
)
33+
return value
34+
35+
36+
def require_non_empty_str_field(raw: dict[str, Any], key: str, *, model: str) -> str:
37+
"""Validate a non-empty string field read from raw."""
38+
if key not in raw:
39+
raise SchemaError(model, key)
40+
value = raw[key]
41+
if not isinstance(value, str) or not value:
42+
raise SchemaError(
43+
model,
44+
key,
45+
hint=f"expected non-empty str, got {type(value).__name__}",
46+
)
47+
return value
48+
49+
50+
def require_non_empty_str_fields(
51+
raw: dict[str, Any],
52+
keys: tuple[str, ...],
53+
*,
54+
model: str,
55+
) -> None:
56+
"""Validate multiple non-empty string fields in raw (ExportEntry pattern)."""
57+
for key in keys:
58+
if key not in raw:
59+
raise SchemaError(model, key)
60+
value = raw[key]
61+
if not isinstance(value, str) or value == "":
62+
raise SchemaError(
63+
model,
64+
key,
65+
hint=f"expected non-empty str, got {type(value).__name__}",
66+
)
67+
68+
69+
def require_truthy(value: Any, *, model: str, field: str) -> Any:
70+
"""Raise missing-field SchemaError when value is falsy.
71+
72+
Treats None, empty string, 0, and other falsy values as missing (no hint),
73+
matching prior CliSessionMeta ``latestRootBlobId`` behavior via ``raw.get``.
74+
"""
75+
if not value:
76+
raise SchemaError(model, field)
77+
return value
78+
79+
80+
def require_type(
81+
value: Any,
82+
expected: type[Any] | tuple[type[Any], ...],
83+
*,
84+
model: str,
85+
field: str,
86+
hint: str | None = None,
87+
) -> Any:
88+
if not isinstance(value, expected):
89+
raise SchemaError(
90+
model,
91+
field,
92+
hint=hint or f"expected {expected!r}, got {type(value).__name__}",
93+
)
94+
return value
95+
96+
97+
def require_optional_str(value: Any, *, model: str, field: str) -> str | None:
98+
if value is not None and not isinstance(value, str):
99+
raise SchemaError(
100+
model,
101+
field,
102+
hint=f"expected str or None, got {type(value).__name__}",
103+
)
104+
return value

models/workspace.py

Lines changed: 4 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from dataclasses import dataclass, field
44
from typing import Any
55

6-
from models.errors import SchemaError
6+
from models.from_dict_validation import require_dict, require_non_empty_str, require_optional_str
77

88

99
@dataclass(frozen=True)
@@ -16,23 +16,7 @@ class Workspace:
1616

1717
@classmethod
1818
def from_dict(cls, raw: dict[str, Any], *, workspace_id: str) -> "Workspace":
19-
if not isinstance(raw, dict):
20-
raise SchemaError(
21-
"Workspace",
22-
"workspace.json",
23-
hint=f"expected object, got {type(raw).__name__}",
24-
)
25-
if not isinstance(workspace_id, str) or not workspace_id:
26-
raise SchemaError(
27-
"Workspace",
28-
"workspaceId",
29-
hint=f"expected non-empty str, got {type(workspace_id).__name__}",
30-
)
31-
folder = raw.get("folder")
32-
if folder is not None and not isinstance(folder, str):
33-
raise SchemaError(
34-
"Workspace",
35-
"folder",
36-
hint=f"expected str or None, got {type(folder).__name__}",
37-
)
19+
raw = require_dict(raw, model="Workspace", field="workspace.json")
20+
require_non_empty_str(workspace_id, model="Workspace", field="workspaceId")
21+
folder = require_optional_str(raw.get("folder"), model="Workspace", field="folder")
3822
return cls(workspace_id=workspace_id, folder=folder, raw=raw)

tests/test_from_dict_validation.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import sys
5+
import unittest
6+
7+
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
8+
if REPO_ROOT not in sys.path:
9+
sys.path.insert(0, REPO_ROOT)
10+
11+
from models.errors import SchemaError
12+
from models.from_dict_validation import (
13+
require_non_empty_str_field,
14+
require_non_empty_str_fields,
15+
)
16+
17+
18+
class RequireNonEmptyStrFieldMessages(unittest.TestCase):
19+
def test_absent_key_raises_missing_required_field(self) -> None:
20+
with self.assertRaises(SchemaError) as cm:
21+
require_non_empty_str_field({}, "composerId", model="TestModel")
22+
self.assertEqual(cm.exception.field, "composerId")
23+
self.assertIn("missing required field", str(cm.exception))
24+
self.assertNotIn("invalid field", str(cm.exception))
25+
26+
def test_wrong_type_raises_invalid_field(self) -> None:
27+
with self.assertRaises(SchemaError) as cm:
28+
require_non_empty_str_field(
29+
{"composerId": 123},
30+
"composerId",
31+
model="TestModel",
32+
)
33+
self.assertEqual(cm.exception.field, "composerId")
34+
self.assertIn("invalid field", str(cm.exception))
35+
self.assertIn("expected non-empty str, got int", str(cm.exception))
36+
self.assertNotIn("missing required field", str(cm.exception))
37+
38+
39+
class RequireNonEmptyStrFieldsMessages(unittest.TestCase):
40+
def test_absent_key_raises_missing_required_field(self) -> None:
41+
with self.assertRaises(SchemaError) as cm:
42+
require_non_empty_str_fields(
43+
{"title": "x", "workspace": "w"},
44+
("log_id", "title", "workspace"),
45+
model="ExportEntry",
46+
)
47+
self.assertEqual(cm.exception.field, "log_id")
48+
self.assertIn("missing required field", str(cm.exception))
49+
self.assertNotIn("invalid field", str(cm.exception))
50+
51+
def test_wrong_type_raises_invalid_field(self) -> None:
52+
with self.assertRaises(SchemaError) as cm:
53+
require_non_empty_str_fields(
54+
{"log_id": 1, "title": "x", "workspace": "w"},
55+
("log_id", "title", "workspace"),
56+
model="ExportEntry",
57+
)
58+
self.assertEqual(cm.exception.field, "log_id")
59+
self.assertIn("invalid field", str(cm.exception))
60+
self.assertIn("expected non-empty str, got int", str(cm.exception))
61+
self.assertNotIn("missing required field", str(cm.exception))
62+
63+
64+
if __name__ == "__main__":
65+
unittest.main()

0 commit comments

Comments
 (0)