Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle recursively serializing a dataclasses as a dictionary. #547

Merged
merged 19 commits into from
Feb 14, 2024
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions ocpp/charge_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
import re
import time
import uuid
from dataclasses import asdict
from typing import Dict, List, Union
from dataclasses import Field, asdict, is_dataclass
from typing import Any, Dict, List, Union, get_args, get_origin

from ocpp.exceptions import NotImplementedError, NotSupportedError, OCPPError
from ocpp.messages import Call, MessageType, unpack, validate_payload
Expand Down Expand Up @@ -69,6 +69,85 @@ def snake_to_camel_case(data):
return data


def _is_dataclass_instance(input: Any) -> bool:
"""Verify if given `input` is a dataclass."""
return is_dataclass(input) and not isinstance(input, type)


def _is_optional_field(field: Field) -> bool:
"""Verify if given `field` allows `None` as value.

The fields `schema` and `host` on the following class would return `False`.
While the fields `post` and `query` return `True`.

@dataclass
class URL:
schema: str,
host: str,
post: Optional[str],
query: Union[None, str]

"""
return get_origin(field.type) is Union and type(None) in get_args(field.type)


def serialize_as_dict(dataclass, remove_empty_optional_fields: bool = True):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the solution can be simplified.

The root of issue #255 is that dataclasses.asdict() only serializes the top level dataclass as a dictionary. Any inner dataclasses are not serializes as a dict.

The body of this function fixes that: recursively serializing a dataclasses as a dictionary.

But this function does more:

  1. remove any optional fields containing None.
  2. change casing to snake_case.

I think we can remove the latter to features of this function.

And, in lines 318 and 378, replace the call to dataclass.asdict() with serialize_as_dict().

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

asdict() corrected in commit d160959

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed the more with commit 2834dbe

"""Serialize the given `dataclass` as a `dict` recursively.


@dataclass
class StatusInfoType:
reason_code: str
additional_info: Optional[str] = None

with_additional_info = StatusInfoType(reason="Unknown",
additional_info="More details")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you put this on a single line, to make the code sample valid Python?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done - satisfied with b3c62d3


assert serialize_as_dict(with_additional_info) == {
'reason': 'Unknown',
'additional_info': 'More details',
}

without_additional_info = StatusInfoType(reason="Unknown")

assert serialize_as_dict(with_additional_info) == {
'reason': 'Unknown',
'additional_info': None,
}

assert serialize_as_dict(with_additional_info,
remove_empty_optional_fields) == {
'reason': 'Unknown',
}


"""
serialized = asdict(dataclass)

for field in dataclass.__dataclass_fields__.values():
# Remove field from serialized output if the field is optional and
# `None`.
if (
remove_empty_optional_fields
and _is_optional_field(field)
and serialized[field.name] is None
):
del serialized[field.name]
continue

value = getattr(dataclass, field.name)
if _is_dataclass_instance(value):
serialized[field.name] = serialize_as_dict(value)
continue

if isinstance(value, list):
for item in value:
if _is_dataclass_instance(item):
serialized[field.name] = [serialize_as_dict(item)]

return snake_to_camel_case(serialized)


def remove_nones(data: Union[List, Dict]) -> Union[List, Dict]:
if isinstance(data, dict):
return {k: remove_nones(v) for k, v in data.items() if v is not None}
Expand Down
12 changes: 6 additions & 6 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ classifiers = [
[tool.poetry.dependencies]
python = "^3.7"
jsonschema = "^4.4.0"
typing-extensions = "^4.0.0"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we drop support for Python 3.7, this dependency is not required right?
typing.get_args and typing.get_origin are both available in Python 3.8,

Copy link
Collaborator Author

@Jared-Newell-Mobility Jared-Newell-Mobility Feb 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah - Corrected with commit f59f346


[tool.poetry.dev-dependencies]
# Starting from Python 3.8, asynctest is replaced with a unittest.mock.AsyncMock in standard library.
Expand Down
47 changes: 44 additions & 3 deletions tests/test_charge_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@

import pytest

from ocpp.charge_point import camel_to_snake_case, remove_nones, snake_to_camel_case
from ocpp.charge_point import (
camel_to_snake_case,
remove_nones,
serialize_as_dict,
snake_to_camel_case,
)
from ocpp.routing import create_route_map, on
from ocpp.v16.call import (
BootNotificationPayload,
Expand All @@ -12,8 +17,14 @@
from ocpp.v16.datatypes import MeterValue, SampledValue
from ocpp.v16.enums import Action
from ocpp.v20 import ChargePoint as cp
from ocpp.v201.call import SetNetworkProfilePayload
from ocpp.v201.datatypes import NetworkConnectionProfileType
from ocpp.v201.call import GetVariablesPayload, SetNetworkProfilePayload
from ocpp.v201.datatypes import (
ComponentType,
EVSEType,
GetVariableDataType,
NetworkConnectionProfileType,
VariableType,
)
from ocpp.v201.enums import OCPPInterfaceType, OCPPTransportType, OCPPVersionType


Expand Down Expand Up @@ -230,3 +241,33 @@ def test_remove_nones_with_list_of_strings():
assert remove_nones(payload) == {
"key": ["ClockAlignedDataInterval", "ConnectionTimeOut"]
}


def test_serialize_as_dict():
payload = GetVariablesPayload(
get_variable_data=[
GetVariableDataType(
component=ComponentType(
name="Component",
evse=EVSEType(id=1),
),
variable=VariableType(name="Variable"),
)
]
)

assert serialize_as_dict(payload) == {
"getVariableData": [
{
"component": {
"name": "Component",
"evse": {
"id": 1,
},
},
"variable": {
"name": "Variable",
},
}
]
}
Loading