Skip to content

Commit

Permalink
Replace pydantic with mashumaro (#496)
Browse files Browse the repository at this point in the history
  • Loading branch information
frenck authored Nov 6, 2023
1 parent cc55b26 commit bfa78fe
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 265 deletions.
184 changes: 35 additions & 149 deletions poetry.lock

Large diffs are not rendered by default.

13 changes: 4 additions & 9 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@ packages = [
]

[tool.poetry.dependencies]
python = "^3.11"
aiohttp = ">=3.0.0"
mashumaro = ">=3.10"
python = "^3.11"
yarl = ">=1.6.0"
pydantic = ">1.8.0"

[tool.poetry.urls]
"Bug Tracker" = "https://github.com/frenck/python-pvoutput/issues"
Expand All @@ -48,6 +48,7 @@ pytest-asyncio = "0.21.1"
pytest-cov = "4.1.0"
ruff = "0.1.4"
safety = "2.4.0b1"
syrupy = "4.6.0"
yamllint = "1.32.0"

[tool.coverage.run]
Expand Down Expand Up @@ -89,9 +90,6 @@ warn_unused_configs = true
warn_unused_ignores = true

[tool.pylint.MASTER]
extension-pkg-whitelist = [
"pydantic"
]
ignore= [
"tests"
]
Expand Down Expand Up @@ -154,10 +152,7 @@ fixture-parentheses = false
known-first-party = ["pvo"]

[tool.ruff.lint.flake8-type-checking]
runtime-evaluated-base-classes = [
"pydantic.BaseModel",
"pydantic.v1.BaseModel",
]
runtime-evaluated-base-classes = ["mashumaro.mixins.orjson.DataClassORJSONMixin"]

[tool.ruff.mccabe]
max-complexity = 25
Expand Down
128 changes: 60 additions & 68 deletions src/pvo/models.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,60 @@
"""Asynchronous client for the PVOutput API."""
from __future__ import annotations

from dataclasses import dataclass
from datetime import date, datetime, time, timezone

try:
from pydantic.v1 import BaseModel, validator
except ImportError: # pragma: no cover
from pydantic import ( # type: ignore[assignment] # pragma: no cover
BaseModel,
validator,
)
from mashumaro import DataClassDictMixin
from mashumaro.config import BaseConfig
from mashumaro.types import SerializationStrategy


class Status(BaseModel):
class NaNisNone(SerializationStrategy):
"""The `NaN` string value should result in None."""

def serialize(self, value: float | None) -> float | str:
"""Serialize NoneType into NaN."""
if value is None: # pragma: no cover
return "NaN"
return value

def deserialize(self, value: float | str) -> float | None:
"""Deserialize NaN into NoneType."""
if value == "NaN":
return None
return float(value)


class DateStrategy(SerializationStrategy):
"""String serialization strategy to handle the date format."""

def serialize(self, value: date) -> str:
"""Serialize date to their specific format."""
return datetime.strftime(value, "%Y%m%d")

def deserialize(self, value: str) -> date | None:
"""Deserialize their date format to a date."""
if not value:
return None

return datetime.strptime(value, "%Y%m%d").replace(tzinfo=timezone.utc).date()


@dataclass
# pylint: disable-next=too-many-instance-attributes
class Status(DataClassDictMixin):
"""Object holding the latest status information and live output data."""

# pylint: disable-next=too-few-public-methods
class Config(BaseConfig):
"""Mashumaro configuration."""

serialization_strategy = { # noqa: RUF012
date: DateStrategy(),
float: NaNisNone(),
int: NaNisNone(),
}

reported_date: date
reported_time: time

Expand All @@ -40,51 +80,21 @@ def reported_datetime(self) -> datetime:
tzinfo=timezone.utc,
)

@validator(
"energy_consumption",
"energy_generation",
"normalized_output",
"power_consumption",
"power_generation",
"temperature",
"voltage",
pre=True,
)
@classmethod
def filter_not_a_number(
cls,
value: str | float,
) -> str | int | float | None:
"""Filter out NaN values.
Args:
----
value: Value to filter.
Returns:
-------
Filtered value.
"""
return None if value == "NaN" else value

@validator("reported_date", pre=True)
@classmethod
def preparse_date(cls, value: str) -> str:
"""Preparse date so Pydantic understands it.

Args:
----
value: Date value to preparse.
Returns:
-------
Preparsed date value.
"""
return f"{value[:4]}-{value[4:6]}-{value[6:]}"
@dataclass
# pylint: disable-next=too-many-instance-attributes
class System(DataClassDictMixin):
"""Object holding the latest system information."""

# pylint: disable-next=too-few-public-methods
class Config(BaseConfig):
"""Mashumaro configuration."""

class System(BaseModel):
"""Object holding the latest system information."""
serialization_strategy = { # noqa: RUF012
date: DateStrategy(),
float: NaNisNone(),
int: NaNisNone(),
}

array_tilt: float | None
install_date: date | None
Expand All @@ -102,21 +112,3 @@ class System(BaseModel):
system_name: str
system_size: int | None
zipcode: str | None

@validator("install_date", pre=True)
@classmethod
def preparse_date(cls, value: str) -> str | None:
"""Preparse date so Pydantic understands it.
Args:
----
value: Date value to preparse.
Returns:
-------
Preparsed date value.
"""
if not value:
return None

return f"{value[:4]}-{value[4:6]}-{value[6:]}"
78 changes: 41 additions & 37 deletions src/pvo/pvoutput.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,21 +112,23 @@ async def status(self) -> Status:
An PVOutput Status object.
"""
data = await self._request("getstatus.jsp")
return Status.parse_obj(
zip(
[
"reported_date",
"reported_time",
"energy_generation",
"power_generation",
"energy_consumption",
"power_consumption",
"normalized_output",
"temperature",
"voltage",
],
data.split(","),
strict=True,
return Status.from_dict(
dict(
zip(
[
"reported_date",
"reported_time",
"energy_generation",
"power_generation",
"energy_consumption",
"power_consumption",
"normalized_output",
"temperature",
"voltage",
],
data.split(","),
strict=True,
)
),
)

Expand All @@ -138,28 +140,30 @@ async def system(self) -> System:
An PVOutput System object.
"""
data = await self._request("getsystem.jsp")
return System.parse_obj(
zip(
[
"system_name",
"system_size",
"zipcode",
"panels",
"panel_power",
"panel_brand",
"inverters",
"inverter_power",
"inverter_brand",
"orientation",
"array_tilt",
"shade",
"install_date",
"latitude",
"longitude",
"status_interval",
],
data.partition(";")[0].split(","),
strict=True,
return System.from_dict(
dict(
zip(
[
"system_name",
"system_size",
"zipcode",
"panels",
"panel_power",
"panel_brand",
"inverters",
"inverter_power",
"inverter_brand",
"orientation",
"array_tilt",
"shade",
"install_date",
"latitude",
"longitude",
"status_interval",
],
data.partition(";")[0].split(","),
strict=True,
)
),
)

Expand Down
34 changes: 34 additions & 0 deletions tests/__snapshots__/test_pvoutput.ambr
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# serializer version: 1
# name: test_get_status
dict({
'energy_consumption': None,
'energy_generation': 3636.0,
'normalized_output': None,
'power_consumption': None,
'power_generation': 0.0,
'reported_date': '20211222',
'reported_time': '18:00:00',
'temperature': 21.2,
'voltage': 220.1,
})
# ---
# name: test_get_system
dict({
'array_tilt': 20.0,
'install_date': '20180622',
'inverter_brand': 'SolarEdge SE5000H',
'inverter_power': 5000.0,
'inverters': 1.0,
'latitude': 51.1234,
'longitude': 6.1234,
'orientation': 'S',
'panel_brand': 'JA solar JAM-300',
'panel_power': 295.0,
'panels': 17.0,
'shade': 'Low',
'status_interval': 5.0,
'system_name': 'Frenck',
'system_size': 5015.0,
'zipcode': 'CO1',
})
# ---
13 changes: 11 additions & 2 deletions tests/test_pvoutput.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import aiohttp
import pytest
from aresponses import Response, ResponsesMockServer
from syrupy.assertion import SnapshotAssertion

from pvo import PVOutput
from pvo.exceptions import (
Expand Down Expand Up @@ -140,7 +141,9 @@ async def test_communication_error() -> None:
assert await pvoutput._request("test")


async def test_get_status(aresponses: ResponsesMockServer) -> None:
async def test_get_status(
aresponses: ResponsesMockServer, snapshot: SnapshotAssertion
) -> None:
"""Test get status handling."""
aresponses.add(
"pvoutput.org",
Expand Down Expand Up @@ -175,6 +178,8 @@ async def test_get_status(aresponses: ResponsesMockServer) -> None:
assert status.temperature == 21.2
assert status.voltage == 220.1

assert status.to_dict() == snapshot


async def test_get_status_no_data(aresponses: ResponsesMockServer) -> None:
"""Test PVOutput status without data is handled."""
Expand All @@ -191,7 +196,9 @@ async def test_get_status_no_data(aresponses: ResponsesMockServer) -> None:
await pvoutput.status()


async def test_get_system(aresponses: ResponsesMockServer) -> None:
async def test_get_system(
aresponses: ResponsesMockServer, snapshot: SnapshotAssertion
) -> None:
"""Test get system handling."""
aresponses.add(
"pvoutput.org",
Expand Down Expand Up @@ -228,6 +235,8 @@ async def test_get_system(aresponses: ResponsesMockServer) -> None:
assert system.system_size == 5015
assert system.zipcode == "CO1"

assert system.to_dict() == snapshot


async def test_get_system_empty_install_date(aresponses: ResponsesMockServer) -> None:
"""Test get system handling with an empty install date."""
Expand Down

0 comments on commit bfa78fe

Please sign in to comment.