diff --git a/pyproject.toml b/pyproject.toml index 6377bcf..13e085d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "pydantic-settings>=2.11.0", "python-dateutil>=2.9.0.post0", "python-dotenv>=1.2.1", + "pyyaml>=6.0.2", "typing-extensions>=4.14.0", "urllib3>=2.4.0", ] diff --git a/src/codesphere/core/base.py b/src/codesphere/core/base.py index aff6a6f..83536c3 100644 --- a/src/codesphere/core/base.py +++ b/src/codesphere/core/base.py @@ -1,11 +1,13 @@ -from typing import Generic, List, TypeVar +from typing import Any, Generic, List, Literal, TypeVar + +import yaml from pydantic import BaseModel, ConfigDict, RootModel from pydantic.alias_generators import to_camel from ..http_client import APIHttpClient from .handler import _APIOperationExecutor -ModelT = TypeVar("ModelT") +ModelT = TypeVar("ModelT", bound=BaseModel) class ResourceBase(_APIOperationExecutor): @@ -20,6 +22,58 @@ class CamelModel(BaseModel): serialize_by_alias=True, ) + def to_dict( + self, *, by_alias: bool = True, exclude_none: bool = False + ) -> dict[str, Any]: + """Export model as a Python dictionary. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + + Returns: + Dictionary representation of the model. + """ + return self.model_dump(by_alias=by_alias, exclude_none=exclude_none) + + def to_json( + self, + *, + by_alias: bool = True, + exclude_none: bool = False, + indent: int | None = None, + ) -> str: + """Export model as a JSON string. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + indent: Number of spaces for indentation. None for compact output. + + Returns: + JSON string representation of the model. + """ + return self.model_dump_json( + by_alias=by_alias, exclude_none=exclude_none, indent=indent + ) + + def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = False) -> str: + """Export model as a YAML string. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + + Returns: + YAML string representation of the model. + """ + data = self.model_dump( + by_alias=by_alias, exclude_none=exclude_none, mode="json" + ) + return yaml.safe_dump( + data, default_flow_style=False, allow_unicode=True, sort_keys=False + ) + class ResourceList(RootModel[List[ModelT]], Generic[ModelT]): root: List[ModelT] @@ -32,3 +86,67 @@ def __getitem__(self, item): def __len__(self): return len(self.root) + + def to_list( + self, + *, + by_alias: bool = True, + exclude_none: bool = False, + mode: Literal["python", "json"] = "python", + ) -> list[dict[str, Any]]: + """Export all items as a list of dictionaries. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + mode: Serialization mode. "python" returns native Python objects, + "json" returns JSON-compatible types (e.g., datetime as ISO string). + + Returns: + List of dictionary representations. + """ + return [ + item.model_dump(by_alias=by_alias, exclude_none=exclude_none, mode=mode) + for item in self.root + ] + + def to_json( + self, + *, + by_alias: bool = True, + exclude_none: bool = False, + indent: int | None = None, + ) -> str: + """Export all items as a JSON array string. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + indent: Number of spaces for indentation. None for compact output. + + Returns: + JSON array string representation. + """ + import json + + return json.dumps( + self.to_list(by_alias=by_alias, exclude_none=exclude_none, mode="json"), + indent=indent, + ) + + def to_yaml(self, *, by_alias: bool = True, exclude_none: bool = False) -> str: + """Export all items as a YAML string. + + Args: + by_alias: Use camelCase keys (API format) if True, snake_case if False. + exclude_none: Exclude fields with None values if True. + + Returns: + YAML string representation. + """ + return yaml.safe_dump( + self.to_list(by_alias=by_alias, exclude_none=exclude_none, mode="json"), + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + ) diff --git a/src/codesphere/resources/team/domain/manager.py b/src/codesphere/resources/team/domain/manager.py index 7f54163..2a923b5 100644 --- a/src/codesphere/resources/team/domain/manager.py +++ b/src/codesphere/resources/team/domain/manager.py @@ -1,21 +1,17 @@ from typing import List, Union + from pydantic import Field from ....core.base import ResourceList from ....core.handler import _APIOperationExecutor from ....core.operations import AsyncCallable from ....http_client import APIHttpClient -from .schemas import CustomDomainConfig, DomainRouting, RoutingMap -from .resources import Domain from .operations import _CREATE_OP, _GET_OP, _LIST_OP, _UPDATE_OP, _UPDATE_WS_OP +from .resources import Domain +from .schemas import CustomDomainConfig, DomainRouting, RoutingMap class TeamDomainManager(_APIOperationExecutor): - """ - Verwaltet Domains im Kontext eines spezifischen Teams. - Zugriff typischerweise über 'team.domains'. - """ - def __init__(self, http_client: APIHttpClient, team_id: int): self._http_client = http_client self.team_id = team_id diff --git a/src/codesphere/resources/team/domain/schemas.py b/src/codesphere/resources/team/domain/schemas.py index c2f86a0..d6c9ab4 100644 --- a/src/codesphere/resources/team/domain/schemas.py +++ b/src/codesphere/resources/team/domain/schemas.py @@ -1,5 +1,7 @@ from __future__ import annotations + from typing import Dict, List, Optional, TypeAlias + from pydantic import Field, RootModel from ....core.base import CamelModel @@ -31,10 +33,6 @@ class CustomDomainConfig(CamelModel): class DomainRouting(RootModel): - """ - Helper class to build the routing configuration. - """ - root: RoutingMap = Field(default_factory=dict) def add(self, path: str, workspace_ids: List[int]) -> DomainRouting: diff --git a/tests/core/test_base.py b/tests/core/test_base.py index 2b7710b..9e66eb7 100644 --- a/tests/core/test_base.py +++ b/tests/core/test_base.py @@ -1,7 +1,9 @@ -import pytest +import json from dataclasses import dataclass +from datetime import datetime, timezone from unittest.mock import MagicMock +import pytest from pydantic import BaseModel from codesphere.core.base import CamelModel, ResourceBase, ResourceList @@ -91,6 +93,94 @@ class SampleModel(CamelModel): assert model.is_private is False +class TestCamelModelExport: + """Tests for CamelModel export methods.""" + + def test_to_dict_default(self): + """to_dict should export with camelCase keys by default.""" + + class SampleModel(CamelModel): + team_id: int + user_name: str + + model = SampleModel(team_id=1, user_name="test") + result = model.to_dict() + + assert result == {"teamId": 1, "userName": "test"} + + def test_to_dict_snake_case(self): + """to_dict with by_alias=False should export with snake_case keys.""" + + class SampleModel(CamelModel): + team_id: int + user_name: str + + model = SampleModel(team_id=1, user_name="test") + result = model.to_dict(by_alias=False) + + assert result == {"team_id": 1, "user_name": "test"} + + def test_to_dict_exclude_none(self): + """to_dict with exclude_none=True should omit None values.""" + + class SampleModel(CamelModel): + team_id: int + optional_field: str | None = None + + model = SampleModel(team_id=1, optional_field=None) + result = model.to_dict(exclude_none=True) + + assert result == {"teamId": 1} + assert "optionalField" not in result + + def test_to_json_default(self): + """to_json should export as JSON string with camelCase keys.""" + + class SampleModel(CamelModel): + team_id: int + + model = SampleModel(team_id=42) + result = model.to_json() + + assert json.loads(result) == {"teamId": 42} + + def test_to_json_with_indent(self): + """to_json with indent should format output.""" + + class SampleModel(CamelModel): + team_id: int + + model = SampleModel(team_id=42) + result = model.to_json(indent=2) + + assert json.loads(result) == {"teamId": 42} + assert "\n" in result + + def test_to_yaml_default(self): + """to_yaml should export as YAML string with camelCase keys.""" + + class SampleModel(CamelModel): + team_id: int + user_name: str + + model = SampleModel(team_id=1, user_name="test") + result = model.to_yaml() + + assert "teamId: 1" in result + assert "userName: test" in result + + def test_to_yaml_snake_case(self): + """to_yaml with by_alias=False should use snake_case keys.""" + + class SampleModel(CamelModel): + team_id: int + + model = SampleModel(team_id=1) + result = model.to_yaml(by_alias=False) + + assert "team_id: 1" in result + + class TestResourceList: def test_create_with_list(self): """ResourceList should be created with a list of items.""" @@ -150,6 +240,130 @@ class Item(BaseModel): assert list(resource_list) == [] +class TestResourceListExport: + """Tests for ResourceList export methods.""" + + def test_to_list_default(self): + """to_list should export items as list of dicts with camelCase keys.""" + + class Item(CamelModel): + item_id: int + item_name: str + + items = [Item(item_id=1, item_name="a"), Item(item_id=2, item_name="b")] + resource_list = ResourceList[Item](root=items) + result = resource_list.to_list() + + assert result == [ + {"itemId": 1, "itemName": "a"}, + {"itemId": 2, "itemName": "b"}, + ] + + def test_to_list_snake_case(self): + """to_list with by_alias=False should use snake_case keys.""" + + class Item(CamelModel): + item_id: int + + items = [Item(item_id=1)] + resource_list = ResourceList[Item](root=items) + result = resource_list.to_list(by_alias=False) + + assert result == [{"item_id": 1}] + + def test_to_json_default(self): + """to_json should export as JSON array string.""" + + class Item(CamelModel): + item_id: int + + items = [Item(item_id=1), Item(item_id=2)] + resource_list = ResourceList[Item](root=items) + result = resource_list.to_json() + + assert json.loads(result) == [{"itemId": 1}, {"itemId": 2}] + + def test_to_json_with_indent(self): + """to_json with indent should format output.""" + + class Item(CamelModel): + item_id: int + + items = [Item(item_id=1)] + resource_list = ResourceList[Item](root=items) + result = resource_list.to_json(indent=2) + + assert "\n" in result + assert '"itemId": 1' in result + + def test_to_list_empty(self): + """to_list should handle empty lists.""" + + class Item(CamelModel): + item_id: int + + resource_list = ResourceList[Item](root=[]) + result = resource_list.to_list() + + assert result == [] + + def test_to_yaml_default(self): + """to_yaml should export as YAML string.""" + + class Item(CamelModel): + item_id: int + + items = [Item(item_id=1), Item(item_id=2)] + resource_list = ResourceList[Item](root=items) + result = resource_list.to_yaml() + + assert "itemId: 1" in result + assert "itemId: 2" in result + + def test_to_json_with_datetime_field(self): + """to_json should properly serialize datetime fields to ISO format.""" + + class Item(CamelModel): + item_id: int + created_at: datetime + + dt = datetime(2026, 2, 7, 12, 30, 45, tzinfo=timezone.utc) + items = [Item(item_id=1, created_at=dt)] + resource_list = ResourceList[Item](root=items) + result = resource_list.to_json() + + parsed = json.loads(result) + assert parsed == [{"itemId": 1, "createdAt": "2026-02-07T12:30:45Z"}] + + def test_to_list_mode_json(self): + """to_list with mode='json' should return JSON-serializable types.""" + + class Item(CamelModel): + item_id: int + created_at: datetime + + dt = datetime(2026, 2, 7, 12, 30, 45, tzinfo=timezone.utc) + items = [Item(item_id=1, created_at=dt)] + resource_list = ResourceList[Item](root=items) + result = resource_list.to_list(mode="json") + + assert result == [{"itemId": 1, "createdAt": "2026-02-07T12:30:45Z"}] + + def test_to_list_mode_python(self): + """to_list with mode='python' (default) should return native Python types.""" + + class Item(CamelModel): + item_id: int + created_at: datetime + + dt = datetime(2026, 2, 7, 12, 30, 45, tzinfo=timezone.utc) + items = [Item(item_id=1, created_at=dt)] + resource_list = ResourceList[Item](root=items) + result = resource_list.to_list() + + assert result == [{"itemId": 1, "createdAt": dt}] + + class TestResourceBase: def test_initialization_with_http_client(self): """ResourceBase should store the HTTP client.""" diff --git a/uv.lock b/uv.lock index e78960d..85701eb 100644 --- a/uv.lock +++ b/uv.lock @@ -163,6 +163,7 @@ dependencies = [ { name = "pydantic-settings" }, { name = "python-dateutil" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "typing-extensions" }, { name = "urllib3" }, ] @@ -191,6 +192,7 @@ requires-dist = [ { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.2.1" }, { name = "python-dateutil", specifier = ">=2.9.0.post0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "pyyaml", specifier = ">=6.0.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.11.13" }, { name = "typing-extensions", specifier = ">=4.14.0" }, { name = "urllib3", specifier = ">=2.4.0" },