Skip to content

Commit ee2574f

Browse files
committed
Switch to Mashumaro
* Pydantic 2 is tough to use with Home Assistant due to reliance on v1 in it's internal classes. It's trivial to switch to Mashumaro so let's just do that instead.
1 parent c244e51 commit ee2574f

File tree

4 files changed

+74
-79
lines changed

4 files changed

+74
-79
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ readme = "README.md"
99
[tool.poetry.dependencies]
1010
python = "^3.9"
1111
requests = "^2.32.3"
12-
pydantic = "^2.8.2"
1312
rich = "^13.7.1"
1413
typer = "^0.12.3"
1514
ruff = "^0.5.4"
1615
pre-commit = "^3.7.1"
1716
types-requests = "^2.32.0.20240712"
17+
mashumaro = "^3.13.1"
1818

1919
[tool.poetry.group.dev.dependencies]
2020
pytest = "^8.2.2"
@@ -23,6 +23,7 @@ mkdocs-material = "^9.5.29"
2323
mkdocstrings = {extras = ["python"], version = "^0.25.1"}
2424
mypy = "^1.11.0"
2525
pytest-cov = "^5.0.0"
26+
types-setuptools = "^71.1.0.20240724"
2627

2728
[tool.pytest.ini_options]
2829
addopts = "--cov=signalrgb --cov-report=term-missing"

signalrgb/client.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
C lient for interacting with the SignalRGB API.
2+
Client for interacting with the SignalRGB API.
33
44
This module provides a client class for interacting with the SignalRGB API,
55
allowing users to retrieve, apply, and manage lighting effects.
@@ -139,7 +139,7 @@ def _request(self, method: str, endpoint: str, **kwargs) -> dict:
139139
except requests.HTTPError as e:
140140
if e.response is not None:
141141
error_data = e.response.json().get("errors", [{}])[0]
142-
error = Error(**error_data)
142+
error = Error.from_dict(error_data)
143143
raise APIError(f"HTTP error occurred: {e}", error)
144144
raise APIError(f"HTTP error occurred: {e}", Error(title=str(e)))
145145
except RequestException as e:
@@ -161,9 +161,8 @@ def get_effects(self) -> List[Effect]:
161161
>>> print(f"Found {len(effects)} effects")
162162
"""
163163
try:
164-
response = EffectListResponse.model_validate(
165-
self._request("GET", "/api/v1/lighting/effects")
166-
)
164+
response_data = self._request("GET", "/api/v1/lighting/effects")
165+
response = EffectListResponse.from_dict(response_data)
167166
self._ensure_response_ok(response)
168167
effects = response.data
169168
if effects is None or effects.items is None:
@@ -195,9 +194,10 @@ def get_effect(self, effect_id: str) -> Effect:
195194
>>> print(f"Effect name: {effect.attributes.name}")
196195
"""
197196
try:
198-
response = EffectDetailsResponse.model_validate(
199-
self._request("GET", f"/api/v1/lighting/effects/{effect_id}")
197+
response_data = self._request(
198+
"GET", f"/api/v1/lighting/effects/{effect_id}"
200199
)
200+
response = EffectDetailsResponse.from_dict(response_data)
201201
self._ensure_response_ok(response)
202202
if response.data is None:
203203
raise APIError("No effect data in the response")
@@ -260,9 +260,8 @@ def get_current_effect(self) -> Effect:
260260
>>> print(f"Current effect: {current_effect.attributes.name}")
261261
"""
262262
try:
263-
response = EffectDetailsResponse.model_validate(
264-
self._request("GET", "/api/v1/lighting")
265-
)
263+
response_data = self._request("GET", "/api/v1/lighting")
264+
response = EffectDetailsResponse.from_dict(response_data)
266265
self._ensure_response_ok(response)
267266
if response.data is None:
268267
raise APIError("No current effect data in the response")
@@ -288,9 +287,8 @@ def apply_effect(self, effect_id: str) -> None:
288287
>>> print("Effect applied successfully")
289288
"""
290289
try:
291-
response = SignalRGBResponse.model_validate(
292-
self._request("POST", f"/api/v1/effects/{effect_id}/apply")
293-
)
290+
response_data = self._request("POST", f"/api/v1/effects/{effect_id}/apply")
291+
response = SignalRGBResponse.from_dict(response_data)
294292
self._ensure_response_ok(response)
295293
except APIError as e:
296294
if e.error and e.error.code == "not_found":

signalrgb/model.py

Lines changed: 52 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
"""
22
Data models for the SignalRGB API client.
33
4-
This module contains Pydantic models that represent various data structures
4+
This module contains Mashumaro-based models that represent various data structures
55
used in the SignalRGB API, including effects, responses, and error information.
66
These models are used to validate and structure the data received from and sent to the API.
77
"""
88

99
from typing import Any, Dict, List, Optional
10+
from dataclasses import dataclass, field
11+
from mashumaro import DataClassDictMixin
12+
from mashumaro.config import BaseConfig
1013

11-
from pydantic import BaseModel, Field
1214

13-
14-
class Attributes(BaseModel):
15+
@dataclass
16+
class Attributes(DataClassDictMixin):
1517
"""
1618
Attributes of an effect in SignalRGB.
1719
@@ -32,31 +34,20 @@ class Attributes(BaseModel):
3234
uses_video (bool): Indicates whether the effect uses video input.
3335
"""
3436

35-
description: Optional[str] = Field(
36-
default=None, description="Description of the effect"
37-
)
38-
developer_effect: bool = Field(
39-
default=False, description="Whether this is a developer effect"
40-
)
41-
image: Optional[str] = Field(
42-
default=None, description="URL or path to the effect image"
43-
)
44-
name: str = Field(..., description="Name of the effect")
45-
parameters: Dict[str, Any] = Field(
46-
default_factory=dict, description="Effect parameters"
47-
)
48-
publisher: Optional[str] = Field(
49-
default=None, description="Publisher of the effect"
50-
)
51-
uses_audio: bool = Field(default=False, description="Whether the effect uses audio")
52-
uses_input: bool = Field(default=False, description="Whether the effect uses input")
53-
uses_meters: bool = Field(
54-
default=False, description="Whether the effect uses meters"
55-
)
56-
uses_video: bool = Field(default=False, description="Whether the effect uses video")
57-
58-
59-
class Links(BaseModel):
37+
name: str
38+
description: Optional[str] = None
39+
developer_effect: bool = False
40+
image: Optional[str] = None
41+
parameters: Dict[str, Any] = field(default_factory=dict)
42+
publisher: Optional[str] = None
43+
uses_audio: bool = False
44+
uses_input: bool = False
45+
uses_meters: bool = False
46+
uses_video: bool = False
47+
48+
49+
@dataclass
50+
class Links(DataClassDictMixin):
6051
"""
6152
Links associated with an effect in SignalRGB.
6253
@@ -65,14 +56,18 @@ class Links(BaseModel):
6556
6657
Attributes:
6758
apply (Optional[str]): URL to apply the effect, if available.
68-
self (Optional[str]): URL of the effect itself, typically for retrieving its details.
59+
self_link (Optional[str]): URL of the effect itself, typically for retrieving its details.
6960
"""
7061

71-
apply: Optional[str] = Field(default=None, description="URL to apply the effect")
72-
self: Optional[str] = Field(default=None, description="URL of the effect itself")
62+
apply: Optional[str] = None
63+
self_link: Optional[str] = None
64+
65+
class Config(BaseConfig):
66+
aliases = {"self_link": "self"}
7367

7468

75-
class Effect(BaseModel):
69+
@dataclass
70+
class Effect(DataClassDictMixin):
7671
"""
7772
Represents a single effect in SignalRGB.
7873
@@ -86,13 +81,14 @@ class Effect(BaseModel):
8681
type (str): Type of the object, typically 'lighting'.
8782
"""
8883

89-
attributes: Attributes = Field(..., description="Attributes of the effect")
90-
id: str = Field(..., description="Unique identifier of the effect")
91-
links: Links = Field(..., description="Links associated with the effect")
92-
type: str = Field(..., description="Type of the object, typically 'lighting'")
84+
attributes: Attributes
85+
id: str
86+
links: Links
87+
type: str
9388

9489

95-
class EffectList(BaseModel):
90+
@dataclass
91+
class EffectList(DataClassDictMixin):
9692
"""
9793
A list of effects in SignalRGB.
9894
@@ -103,10 +99,11 @@ class EffectList(BaseModel):
10399
items (List[Effect]): A list of Effect objects.
104100
"""
105101

106-
items: List[Effect] = Field(default_factory=list, description="List of effects")
102+
items: List[Effect] = field(default_factory=list)
107103

108104

109-
class Error(BaseModel):
105+
@dataclass
106+
class Error(DataClassDictMixin):
110107
"""
111108
Represents an error returned by the SignalRGB API.
112109
@@ -119,12 +116,13 @@ class Error(BaseModel):
119116
title (str): A brief title or summary of the error.
120117
"""
121118

122-
code: Optional[str] = Field(default=None, description="Error code")
123-
detail: Optional[str] = Field(default=None, description="Detailed error message")
124-
title: str = Field(..., description="Error title")
119+
title: str
120+
code: Optional[str] = None
121+
detail: Optional[str] = None
125122

126123

127-
class SignalRGBResponse(BaseModel):
124+
@dataclass
125+
class SignalRGBResponse(DataClassDictMixin):
128126
"""
129127
Base class for responses from the SignalRGB API.
130128
@@ -141,18 +139,15 @@ class SignalRGBResponse(BaseModel):
141139
errors (List[Error]): A list of Error objects if any errors occurred.
142140
"""
143141

144-
api_version: str = Field(..., description="API version")
145-
id: int = Field(..., description="Response ID")
146-
method: str = Field(..., description="HTTP method used")
147-
params: Dict[str, Any] = Field(
148-
default_factory=dict, description="Request parameters"
149-
)
150-
status: str = Field(..., description="Response status")
151-
errors: List[Error] = Field(
152-
default_factory=list, description="List of errors if any"
153-
)
142+
api_version: str
143+
id: int
144+
method: str
145+
status: str
146+
params: Dict[str, Any] = field(default_factory=dict)
147+
errors: List[Error] = field(default_factory=list)
154148

155149

150+
@dataclass
156151
class EffectDetailsResponse(SignalRGBResponse):
157152
"""
158153
Response model for requests that return details of a single effect.
@@ -164,9 +159,10 @@ class EffectDetailsResponse(SignalRGBResponse):
164159
data (Optional[Effect]): The details of the requested effect, if available.
165160
"""
166161

167-
data: Optional[Effect] = Field(default=None, description="Effect details")
162+
data: Optional[Effect] = None
168163

169164

165+
@dataclass
170166
class EffectListResponse(SignalRGBResponse):
171167
"""
172168
Response model for requests that return a list of effects.
@@ -178,4 +174,4 @@ class EffectListResponse(SignalRGBResponse):
178174
data (Optional[EffectList]): The list of effects returned by the API, if available.
179175
"""
180176

181-
data: Optional[EffectList] = Field(default=None, description="List of effects")
177+
data: Optional[EffectList] = None

tests/test_model.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ def load_json_data(self, filename: str) -> Dict[str, Any]:
2121

2222
def test_effect_deserialization(self):
2323
data = self.load_json_data("effect.json")
24-
response = EffectDetailsResponse.model_validate(data)
24+
response = EffectDetailsResponse.from_dict(data)
2525
self.assertEqual(response.status, "ok")
2626
self.assertIsInstance(response.data, Effect)
2727

@@ -41,15 +41,15 @@ def test_effect_deserialization(self):
4141

4242
# Test Links
4343
self.assertIsInstance(effect.links.apply, str)
44-
self.assertIsInstance(effect.links.self, str)
44+
self.assertIsInstance(effect.links.self_link, str)
4545

4646
# Test single effect
4747
self.assertIsInstance(effect.id, str)
4848
self.assertIsInstance(effect.type, str)
4949

5050
def test_effect_list_deserialization(self):
5151
data = self.load_json_data("effect_list.json")
52-
response = EffectListResponse.model_validate(data)
52+
response = EffectListResponse.from_dict(data)
5353
self.assertEqual(response.status, "ok")
5454
self.assertIsInstance(response.data, EffectList)
5555

@@ -64,7 +64,7 @@ def test_effect_list_deserialization(self):
6464

6565
def test_effect_error_deserialization(self):
6666
data = self.load_json_data("error.json")
67-
response = EffectDetailsResponse.model_validate(data)
67+
response = EffectDetailsResponse.from_dict(data)
6868
self.assertEqual(response.status, "error")
6969
self.assertIsInstance(response.errors, list)
7070
self.assertGreater(len(response.errors), 0)
@@ -88,7 +88,7 @@ def test_attributes_model(self):
8888
"uses_meters": True,
8989
"uses_video": False,
9090
}
91-
attrs = Attributes.model_validate(attrs_data)
91+
attrs = Attributes.from_dict(attrs_data)
9292
for field, value in attrs_data.items():
9393
self.assertEqual(getattr(attrs, field), value)
9494

@@ -97,9 +97,9 @@ def test_links_model(self):
9797
"apply": "http://api.example.com/apply",
9898
"self": "http://api.example.com/effect/1",
9999
}
100-
links = Links.model_validate(links_data)
100+
links = Links.from_dict(links_data)
101101
self.assertEqual(links.apply, links_data["apply"])
102-
self.assertEqual(links.self, links_data["self"])
102+
self.assertEqual(links.self_link, links_data["self"])
103103

104104
def test_signal_rgb_response(self):
105105
response_data = {
@@ -110,7 +110,7 @@ def test_signal_rgb_response(self):
110110
"status": "ok",
111111
"errors": [],
112112
}
113-
response = SignalRGBResponse.model_validate(response_data)
113+
response = SignalRGBResponse.from_dict(response_data)
114114
self.assertEqual(response.api_version, "1.0")
115115
self.assertEqual(response.id, 12345)
116116
self.assertEqual(response.method, "GET")
@@ -124,7 +124,7 @@ def test_error_model(self):
124124
"title": "Internal Server Error",
125125
"detail": "An unexpected error occurred",
126126
}
127-
error = Error.model_validate(error_data)
127+
error = Error.from_dict(error_data)
128128
self.assertEqual(error.code, "500")
129129
self.assertEqual(error.title, "Internal Server Error")
130130
self.assertEqual(error.detail, "An unexpected error occurred")

0 commit comments

Comments
 (0)