Skip to content

Commit 1326f90

Browse files
author
Datata1
committed
feat(model-export): provide model methods to export data
1 parent 8ad8739 commit 1326f90

File tree

4 files changed

+297
-2
lines changed

4 files changed

+297
-2
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ dependencies = [
1717
"pydantic-settings>=2.11.0",
1818
"python-dateutil>=2.9.0.post0",
1919
"python-dotenv>=1.2.1",
20+
"pyyaml>=6.0.2",
2021
"typing-extensions>=4.14.0",
2122
"urllib3>=2.4.0",
2223
]

src/codesphere/core/base.py

Lines changed: 135 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
from typing import Generic, List, TypeVar
1+
from typing import Any, Generic, List, TypeVar
2+
23
from pydantic import BaseModel, ConfigDict, RootModel
34
from pydantic.alias_generators import to_camel
45

@@ -20,6 +21,68 @@ class CamelModel(BaseModel):
2021
serialize_by_alias=True,
2122
)
2223

24+
def to_dict(
25+
self, *, by_alias: bool = True, exclude_none: bool = False
26+
) -> dict[str, Any]:
27+
"""Export model as a Python dictionary.
28+
29+
Args:
30+
by_alias: Use camelCase keys (API format) if True, snake_case if False.
31+
exclude_none: Exclude fields with None values if True.
32+
33+
Returns:
34+
Dictionary representation of the model.
35+
"""
36+
return self.model_dump(by_alias=by_alias, exclude_none=exclude_none)
37+
38+
def to_json(
39+
self,
40+
*,
41+
by_alias: bool = True,
42+
exclude_none: bool = False,
43+
indent: int | None = None,
44+
) -> str:
45+
"""Export model as a JSON string.
46+
47+
Args:
48+
by_alias: Use camelCase keys (API format) if True, snake_case if False.
49+
exclude_none: Exclude fields with None values if True.
50+
indent: Number of spaces for indentation. None for compact output.
51+
52+
Returns:
53+
JSON string representation of the model.
54+
"""
55+
return self.model_dump_json(
56+
by_alias=by_alias, exclude_none=exclude_none, indent=indent
57+
)
58+
59+
def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = False) -> str:
60+
"""Export model as a YAML string.
61+
62+
Requires PyYAML to be installed (optional dependency).
63+
64+
Args:
65+
by_alias: Use camelCase keys (API format) if True, snake_case if False.
66+
exclude_none: Exclude fields with None values if True.
67+
68+
Returns:
69+
YAML string representation of the model.
70+
71+
Raises:
72+
ImportError: If PyYAML is not installed.
73+
"""
74+
try:
75+
import yaml
76+
except ImportError:
77+
raise ImportError(
78+
"PyYAML is required for YAML export. "
79+
"Install it with: pip install pyyaml"
80+
)
81+
data = self.to_dict(by_alias=by_alias, exclude_none=exclude_none)
82+
return yaml.dump(
83+
data, default_flow_style=False, allow_unicode=True, sort_keys=False
84+
)
85+
2386

2487
class ResourceList(RootModel[List[ModelT]], Generic[ModelT]):
2588
root: List[ModelT]
@@ -32,3 +95,74 @@ def __getitem__(self, item):
3295

3396
def __len__(self):
3497
return len(self.root)
98+
99+
def to_list(
100+
self, *, by_alias: bool = True, exclude_none: bool = False
101+
) -> list[dict[str, Any]]:
102+
"""Export all items as a list of dictionaries.
103+
104+
Args:
105+
by_alias: Use camelCase keys (API format) if True, snake_case if False.
106+
exclude_none: Exclude fields with None values if True.
107+
108+
Returns:
109+
List of dictionary representations.
110+
"""
111+
return [
112+
item.model_dump(by_alias=by_alias, exclude_none=exclude_none)
113+
if hasattr(item, "model_dump")
114+
else item
115+
for item in self.root
116+
]
117+
118+
def to_json(
119+
self,
120+
*,
121+
by_alias: bool = True,
122+
exclude_none: bool = False,
123+
indent: int | None = None,
124+
) -> str:
125+
"""Export all items as a JSON array string.
126+
127+
Args:
128+
by_alias: Use camelCase keys (API format) if True, snake_case if False.
129+
exclude_none: Exclude fields with None values if True.
130+
indent: Number of spaces for indentation. None for compact output.
131+
132+
Returns:
133+
JSON array string representation.
134+
"""
135+
import json
136+
137+
return json.dumps(
138+
self.to_list(by_alias=by_alias, exclude_none=exclude_none), indent=indent
139+
)
140+
141+
def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = False) -> str:
142+
"""Export all items as a YAML string.
143+
144+
Requires PyYAML to be installed (optional dependency).
145+
146+
Args:
147+
by_alias: Use camelCase keys (API format) if True, snake_case if False.
148+
exclude_none: Exclude fields with None values if True.
149+
150+
Returns:
151+
YAML string representation.
152+
153+
Raises:
154+
ImportError: If PyYAML is not installed.
155+
"""
156+
try:
157+
import yaml
158+
except ImportError:
159+
raise ImportError(
160+
"PyYAML is required for YAML export. "
161+
"Install it with: pip install pyyaml"
162+
)
163+
return yaml.dump(
164+
self.to_list(by_alias=by_alias, exclude_none=exclude_none),
165+
default_flow_style=False,
166+
allow_unicode=True,
167+
sort_keys=False,
168+
)

tests/core/test_base.py

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import pytest
21
from dataclasses import dataclass
32
from unittest.mock import MagicMock
43

4+
import pytest
55
from pydantic import BaseModel
66

77
from codesphere.core.base import CamelModel, ResourceBase, ResourceList
@@ -91,6 +91,96 @@ class SampleModel(CamelModel):
9191
assert model.is_private is False
9292

9393

94+
class TestCamelModelExport:
95+
"""Tests for CamelModel export methods."""
96+
97+
def test_to_dict_default(self):
98+
"""to_dict should export with camelCase keys by default."""
99+
100+
class SampleModel(CamelModel):
101+
team_id: int
102+
user_name: str
103+
104+
model = SampleModel(team_id=1, user_name="test")
105+
result = model.to_dict()
106+
107+
assert result == {"teamId": 1, "userName": "test"}
108+
109+
def test_to_dict_snake_case(self):
110+
"""to_dict with by_alias=False should export with snake_case keys."""
111+
112+
class SampleModel(CamelModel):
113+
team_id: int
114+
user_name: str
115+
116+
model = SampleModel(team_id=1, user_name="test")
117+
result = model.to_dict(by_alias=False)
118+
119+
assert result == {"team_id": 1, "user_name": "test"}
120+
121+
def test_to_dict_exclude_none(self):
122+
"""to_dict with exclude_none=True should omit None values."""
123+
124+
class SampleModel(CamelModel):
125+
team_id: int
126+
optional_field: str | None = None
127+
128+
model = SampleModel(team_id=1, optional_field=None)
129+
result = model.to_dict(exclude_none=True)
130+
131+
assert result == {"teamId": 1}
132+
assert "optionalField" not in result
133+
134+
def test_to_json_default(self):
135+
"""to_json should export as JSON string with camelCase keys."""
136+
137+
class SampleModel(CamelModel):
138+
team_id: int
139+
140+
model = SampleModel(team_id=42)
141+
result = model.to_json()
142+
143+
assert result == '{"teamId":42}'
144+
145+
def test_to_json_with_indent(self):
146+
"""to_json with indent should format output."""
147+
148+
class SampleModel(CamelModel):
149+
team_id: int
150+
151+
model = SampleModel(team_id=42)
152+
result = model.to_json(indent=2)
153+
154+
assert '"teamId": 42' in result
155+
assert "\n" in result
156+
157+
def test_to_yaml_import_error(self):
158+
"""to_yaml should raise ImportError if PyYAML is not installed."""
159+
import sys
160+
from unittest.mock import patch
161+
162+
class SampleModel(CamelModel):
163+
team_id: int
164+
165+
model = SampleModel(team_id=1)
166+
167+
with patch.dict(sys.modules, {"yaml": None}):
168+
# Force reimport to trigger ImportError
169+
with pytest.raises(ImportError, match="PyYAML is required"):
170+
# We need to actually make the import fail
171+
import builtins
172+
173+
original_import = builtins.__import__
174+
175+
def mock_import(name, *args, **kwargs):
176+
if name == "yaml":
177+
raise ImportError("No module named 'yaml'")
178+
return original_import(name, *args, **kwargs)
179+
180+
with patch.object(builtins, "__import__", mock_import):
181+
model.to_yaml()
182+
183+
94184
class TestResourceList:
95185
def test_create_with_list(self):
96186
"""ResourceList should be created with a list of items."""
@@ -150,6 +240,74 @@ class Item(BaseModel):
150240
assert list(resource_list) == []
151241

152242

243+
class TestResourceListExport:
244+
"""Tests for ResourceList export methods."""
245+
246+
def test_to_list_default(self):
247+
"""to_list should export items as list of dicts with camelCase keys."""
248+
249+
class Item(CamelModel):
250+
item_id: int
251+
item_name: str
252+
253+
items = [Item(item_id=1, item_name="a"), Item(item_id=2, item_name="b")]
254+
resource_list = ResourceList[Item](root=items)
255+
result = resource_list.to_list()
256+
257+
assert result == [
258+
{"itemId": 1, "itemName": "a"},
259+
{"itemId": 2, "itemName": "b"},
260+
]
261+
262+
def test_to_list_snake_case(self):
263+
"""to_list with by_alias=False should use snake_case keys."""
264+
265+
class Item(CamelModel):
266+
item_id: int
267+
268+
items = [Item(item_id=1)]
269+
resource_list = ResourceList[Item](root=items)
270+
result = resource_list.to_list(by_alias=False)
271+
272+
assert result == [{"item_id": 1}]
273+
274+
def test_to_json_default(self):
275+
"""to_json should export as JSON array string."""
276+
277+
class Item(CamelModel):
278+
item_id: int
279+
280+
items = [Item(item_id=1), Item(item_id=2)]
281+
resource_list = ResourceList[Item](root=items)
282+
result = resource_list.to_json()
283+
284+
assert result == '[{"itemId": 1}, {"itemId": 2}]'
285+
286+
def test_to_json_with_indent(self):
287+
"""to_json with indent should format output."""
288+
289+
class Item(CamelModel):
290+
item_id: int
291+
292+
items = [Item(item_id=1)]
293+
resource_list = ResourceList[Item](root=items)
294+
result = resource_list.to_json(indent=2)
295+
296+
assert "\n" in result
297+
assert '"itemId": 1' in result
298+
299+
def test_to_list_empty(self):
300+
"""to_list should handle empty lists."""
301+
302+
class Item(CamelModel):
303+
item_id: int
304+
305+
resource_list = ResourceList[Item](root=[])
306+
result = resource_list.to_list()
307+
308+
assert result == []
309+
310+
153311
class TestResourceBase:
154312
def test_initialization_with_http_client(self):
155313
"""ResourceBase should store the HTTP client."""

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)