Skip to content

Commit

Permalink
Merge branch 'master' into v2.0.1-Action-enums-corrected
Browse files Browse the repository at this point in the history
  • Loading branch information
Jared-Newell-Mobility committed Feb 14, 2024
2 parents acba23f + 6ccb1a9 commit c0f0366
Show file tree
Hide file tree
Showing 4 changed files with 176 additions and 9 deletions.
35 changes: 35 additions & 0 deletions .readthedocs.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Read the Docs configuration file for Sphinx projects
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details

# Required
version: 2

# Set the OS, Python version and other tools you might need
build:
os: ubuntu-lts-latest
tools:
python: "3.12"
# You can also specify other tool versions:
# nodejs: "20"
# rust: "1.70"
# golang: "1.20"

# Build documentation in the "docs/" directory with Sphinx
sphinx:
configuration: docs/source/conf.py
# You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
# builder: "dirhtml"
# Fail on all warnings to avoid broken references
# fail_on_warning: true

# Optionally build your docs in additional formats such as PDF and ePub
# formats:
# - pdf
# - epub

# Optional but recommended, declare the Python requirements required
# to build your documentation
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
# python:
# install:
# - requirements: docs/requirements.txt
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Change log

- [#547](https://github.com/mobilityhouse/ocpp/pull/547) Feat: Handle recursively serializing a dataclasses as a dictionary Thanks [@MacDue](https://github.com/MacDue)
- [#601](https://github.com/mobilityhouse/ocpp/issues/601) Fix case conversion for soc in non "State of Charge" context
- [#523](https://github.com/mobilityhouse/ocpp/issues/523) The serialisation of soc to SoC should not occur in camel case if it is existing at the beginning of a field
- [#515](https://github.com/mobilityhouse/ocpp/issues/515) Update Readthedocs configuration
- [#602](https://github.com/mobilityhouse/ocpp/issues/602) Correct v2g serialisation/deserialisation
- [#557](https://github.com/mobilityhouse/ocpp/issues/557) OCPP 2.0.1 Wrong data type in CostUpdated total_cost
- [#564](https://github.com/mobilityhouse/ocpp/issues/564) Add support For Python 3.11 and 3.12
Expand Down
77 changes: 72 additions & 5 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 @@ -53,7 +53,9 @@ def snake_to_camel_case(data):
if isinstance(data, dict):
camel_case_dict = {}
for key, value in data.items():
key = key.replace("soc", "SoC")
key = key.replace("soc", "SoC").replace("_SoCket", "Socket")
key = key.replace("_v2x", "V2X")
key = key.replace("soc_limit_reached", "SOCLimitReached")
key = key.replace("_v2x", "V2X").replace("_v2g", "V2G")
components = key.split("_")
key = components[0] + "".join(x[:1].upper() + x[1:] for x in components[1:])
Expand All @@ -71,6 +73,71 @@ 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):
"""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"
)
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,
}
"""
serialized = asdict(dataclass)

for field in dataclass.__dataclass_fields__.values():

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 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 Expand Up @@ -244,7 +311,7 @@ async def _handle_call(self, msg):

return

temp_response_payload = asdict(response)
temp_response_payload = serialize_as_dict(response)

# Remove nones ensures that we strip out optional arguments
# which were not set and have a default value of None
Expand Down Expand Up @@ -306,7 +373,7 @@ async def call(self, payload, suppress=True, unique_id=None):
CallError.
"""
camel_case_payload = snake_to_camel_case(asdict(payload))
camel_case_payload = snake_to_camel_case(serialize_as_dict(payload))

unique_id = (
unique_id if unique_id is not None else str(self._unique_id_generator())
Expand Down
69 changes: 65 additions & 4 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.messages import Call
from ocpp.routing import after, create_route_map, on
from ocpp.v16 import ChargePoint as cp_16
Expand All @@ -11,8 +16,15 @@
from ocpp.v16.datatypes import MeterValue, SampledValue
from ocpp.v16.enums import Action, RegistrationStatus
from ocpp.v201 import ChargePoint as cp_201
from ocpp.v201.call import SetNetworkProfile
from ocpp.v201.datatypes import NetworkConnectionProfileType
from ocpp.v201.call import GetVariables as v201GetVariables
from ocpp.v201.call import SetNetworkProfile as v201SetNetworkProfile
from ocpp.v201.datatypes import (
ComponentType,
EVSEType,
GetVariableDataType,
NetworkConnectionProfileType,
VariableType,
)
from ocpp.v201.enums import OCPPInterfaceType, OCPPTransportType, OCPPVersionType


Expand Down Expand Up @@ -54,6 +66,7 @@ def heartbeat(self, **kwargs):
({"fullSoC": 100}, {"full_soc": 100}),
({"evMinV2XEnergyRequest": 200}, {"ev_min_v2x_energy_request": 200}),
({"v2xChargingCtrlr": 200}, {"v2x_charging_ctrlr": 200}),
({"webSocketPingInterval": 200}, {"web_socket_ping_interval": 200}),
({"signV2GCertificate": 200}, {"sign_v2g_certificate": 200}),
(
{"v2gCertificateInstallationEnabled": 200},
Expand All @@ -71,8 +84,10 @@ def test_camel_to_snake_case(test_input, expected):
[
({"transaction_id": "74563478"}, {"transactionId": "74563478"}),
({"full_soc": 100}, {"fullSoC": 100}),
({"soc_limit_reached": 200}, {"SoCLimitReached": 200}),
({"ev_min_v2x_energy_request": 200}, {"evMinV2XEnergyRequest": 200}),
({"v2x_charging_ctrlr": 200}, {"v2xChargingCtrlr": 200}),
({"web_socket_ping_interval": 200}, {"webSocketPingInterval": 200}),
({"sign_v2g_certificate": 200}, {"signV2GCertificate": 200}),
(
{"v2g_certificate_installation_enabled": 200},
Expand Down Expand Up @@ -122,7 +137,9 @@ def test_nested_remove_nones():
apn=None,
)

payload = SetNetworkProfile(configuration_slot=1, connection_data=connection_data)
payload = v201SetNetworkProfile(
configuration_slot=1, connection_data=connection_data
)
payload = asdict(payload)

assert expected_payload == remove_nones(payload)
Expand Down Expand Up @@ -243,6 +260,50 @@ def test_remove_nones_with_list_of_strings():
}


def test_serialize_as_dict():
"""
Test recursively serializing a dataclasses as a dictionary.
"""
# Setup
expected = camel_to_snake_case(
{
"getVariableData": [
{
"component": {
"name": "Component",
"instance": None,
"evse": {
"id": 1,
"connectorId": None,
},
},
"variable": {
"name": "Variable",
"instance": None,
},
"attributeType": None,
}
],
"customData": None,
}
)

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

# Execute / Assert
assert serialize_as_dict(payload) == expected


@pytest.mark.asyncio
async def test_call_unique_id_added_to_handler_args_correctly(connection):
"""
Expand Down

0 comments on commit c0f0366

Please sign in to comment.