Skip to content

Commit

Permalink
refactor: Migrate to pydantic 2.x
Browse files Browse the repository at this point in the history
  • Loading branch information
bcyran committed Oct 2, 2023
1 parent c26616a commit a6b791d
Show file tree
Hide file tree
Showing 6 changed files with 33 additions and 49 deletions.
38 changes: 11 additions & 27 deletions src/philipstv/_data.py
Original file line number Diff line number Diff line change
@@ -1,60 +1,44 @@
import json
import logging
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from typing import Optional

from appdirs import user_data_dir
from pydantic import BaseModel, BaseSettings, ValidationError
from pydantic.env_settings import SettingsSourceCallable
from pydantic import BaseModel, ValidationError

_LOGGER = logging.getLogger(__name__)


DATA_FILE = Path(user_data_dir("philipstv", "cyran.dev")) / "data.json"


def json_data_source(settings: BaseSettings) -> Dict[str, Any]:
try:
_LOGGER.debug("Trying to load application data from %s", DATA_FILE)
return json.loads(DATA_FILE.read_text()) # type: ignore [no-any-return]
except FileNotFoundError:
_LOGGER.debug("Data file not found")
return {}
finally:
_LOGGER.debug("Application data loaded successfully")


class HostData(BaseModel):
host: str
id: str
key: str


class PhilipsTVData(BaseSettings):
class PhilipsTVData(BaseModel):
last_host: HostData

@classmethod
def load(cls) -> Optional["PhilipsTVData"]:
try:
return cls()
_LOGGER.debug("Trying to load application data from %s", DATA_FILE)
return cls.model_validate(json.loads(DATA_FILE.read_text()))
except FileNotFoundError:
_LOGGER.debug("Data file not found")
return None
except ValidationError:
return None
finally:
_LOGGER.debug("Application data loaded successfully")

def save(self) -> None:
_LOGGER.debug("Saving application data to %s", DATA_FILE)
if not DATA_FILE.exists():
_LOGGER.debug("Data file doesn't exist, creating")
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
DATA_FILE.touch()
DATA_FILE.write_text(self.json())
DATA_FILE.write_text(self.model_dump_json())
_LOGGER.debug("Application data saved successfully")

class Config:
@classmethod
def customise_sources(
cls,
init_settings: SettingsSourceCallable,
env_settings: SettingsSourceCallable,
file_secret_settings: SettingsSourceCallable,
) -> Tuple[SettingsSourceCallable, ...]:
return (init_settings, json_data_source)
11 changes: 6 additions & 5 deletions src/philipstv/model/ambilight.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from typing import Dict, Tuple, Union

from pydantic import RootModel
from pydantic.fields import Field

from .base import APIObject, StrEnum
Expand Down Expand Up @@ -92,23 +93,23 @@ class AmbilightLayer(APIObject):
"""Colors of pixels on the bottom edge."""


class AmbilightColors(APIObject):
class AmbilightColors(APIObject, RootModel[Dict[str, AmbilightLayer]]):
"""Model of full Amblight per-pixel color definition.
This model is actually a wrapper around a dict since layer names are not predefined.
Values should be set as dict using ``__root__`` kwarg in constructor and accessed as in normal
Values should be set as dict passed to constructor and accessed as in normal
dict using indexing::
colors = AmbilightColors(__root__={"layer1": AmbilightLayer()})
colors = AmbilightColors({"layer1": AmbilightLayer()})
layer1 = colors["layer1"]
"""

__root__: Dict[str, AmbilightLayer] = {}
root: Dict[str, AmbilightLayer]
"""Mapping of layer name to :class:`AmbilightLayer`."""

def __getitem__(self, item: str) -> AmbilightLayer:
return self.__root__[item]
return self.root[item]


AmbilightColorSettings = Union[
Expand Down
11 changes: 4 additions & 7 deletions src/philipstv/model/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ class APIObject(BaseModel):
"""

model_config = {"populate_by_name": True, "use_enum_values": True}

def dump(self) -> Any:
"""Dump the object's JSON data.
Expand All @@ -25,8 +27,7 @@ def dump(self) -> Any:
Model's JSON data.
"""
data = super().dict(by_alias=True)
return data["__root__"] if self.__custom_root_type__ else data
return self.model_dump(by_alias=True)

@classmethod
def parse(cls: Type[_SelfAPIObject], raw: Any) -> _SelfAPIObject:
Expand All @@ -45,11 +46,7 @@ def parse(cls: Type[_SelfAPIObject], raw: Any) -> _SelfAPIObject:
ValidationError: if given JSON data cannot be parsed into this model.
"""
return cls.parse_obj(raw)

class Config:
allow_population_by_field_name = True
use_enum_values = True
return cls.model_validate(raw)


class StrEnum(str, Enum):
Expand Down
5 changes: 3 additions & 2 deletions src/philipstv/remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ def pair(self, pin_callback: PinCallback, id: Optional[str] = None) -> Credentia
id=id,
device_name=uname_info.node,
device_os=uname_info.system,
app_id=69,
app_id="69",
app_name="philipstv",
type="native",
)
Expand Down Expand Up @@ -273,7 +273,8 @@ def set_ambilight_color(
if set_bottom := (bottom or color):
sides["bottom"] = self._create_ambilight_side(set_bottom, topology.bottom)

colors = AmbilightColors(__root__={"layer1": AmbilightLayer(**sides)})
# pydantic.mypy plugin issue: https://github.com/pydantic/pydantic/discussions/7418
colors = AmbilightColors({"layer1": AmbilightLayer(**sides)}) # type: ignore[misc]
self._api.set_ambilight_cached(colors)

def _create_ambilight_side(
Expand Down
12 changes: 6 additions & 6 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,9 +107,9 @@ def test_saves_data(data_file: Path, remote: Mock) -> None:
run("--host", given_host, "--id", given_id, "--key", given_key, "--save", "power", "get")

assert data_file.exists() is True
assert data_file.read_text() == json.dumps(
{"last_host": {"host": given_host, "id": given_id, "key": given_key}}
)
assert json.loads(data_file.read_text()) == {
"last_host": {"host": given_host, "id": given_id, "key": given_key}
}


def test_reads_saved_data(data_file: Path, remote: Mock) -> None:
Expand Down Expand Up @@ -152,9 +152,9 @@ def test_pair_save(remote: Mock, data_file: Path) -> None:
run("--host", given_host, "--id", given_id, "--save", "pair")

assert data_file.exists() is True
assert data_file.read_text() == json.dumps(
{"last_host": {"host": given_host, "id": given_id, "key": given_key}}
)
assert json.loads(data_file.read_text()) == {
"last_host": {"host": given_host, "id": given_id, "key": given_key}
}


def test_pair_error(remote: Mock) -> None:
Expand Down
5 changes: 3 additions & 2 deletions tests/test_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -265,8 +265,9 @@ def test_set_ambilight_color_sides(api_mock: Mock) -> None:
)

api_mock.set_ambilight_cached.assert_called_once_with(
AmbilightColors(
__root__={
# pydantic.mypy plugin issue: https://github.com/pydantic/pydantic/discussions/7418
AmbilightColors( # type: ignore[misc]
{
"layer1": AmbilightLayer(
left={str(point): left_color for point in range(topology.left)},
top={str(point): top_color for point in range(topology.top)},
Expand Down

0 comments on commit a6b791d

Please sign in to comment.