From b08823665504d3b114af84629587a7378320d4f9 Mon Sep 17 00:00:00 2001 From: Sebastiaan la Fleur Date: Thu, 31 Jul 2025 12:43:19 +0200 Subject: [PATCH 1/7] message_id should be available in all ppbc messages, split up type of messages with a message id vs without and ensure that to_dict is json serializable. --- src/s2python/message.py | 6 +++++- src/s2python/ppbc/ppbc_end_interruption_instruction.py | 1 + src/s2python/ppbc/ppbc_power_profile_status.py | 2 ++ src/s2python/ppbc/ppbc_start_interruption_instruction.py | 1 + src/s2python/validate_values_mixin.py | 2 +- 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/s2python/message.py b/src/s2python/message.py index 3467a57..78df8fd 100644 --- a/src/s2python/message.py +++ b/src/s2python/message.py @@ -76,7 +76,7 @@ Transition, ) -S2Message = Union[ +S2MessageWithID = Union[ DDBCAverageDemandRateForecast, DDBCInstruction, DDBCSystemDescription, @@ -111,6 +111,10 @@ InstructionStatusUpdate, PowerForecast, PowerMeasurement, +] + +S2Message = [ + S2MessageWithID, ReceptionStatus, ] diff --git a/src/s2python/ppbc/ppbc_end_interruption_instruction.py b/src/s2python/ppbc/ppbc_end_interruption_instruction.py index d38a454..1a4e3ff 100644 --- a/src/s2python/ppbc/ppbc_end_interruption_instruction.py +++ b/src/s2python/ppbc/ppbc_end_interruption_instruction.py @@ -16,6 +16,7 @@ class PPBCEndInterruptionInstruction(GenPPBCEndInterruptionInstruction, S2Messag model_config["validate_assignment"] = True id: uuid.UUID = GenPPBCEndInterruptionInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + message_id: uuid.UUID = GenPPBCEndInterruptionInstruction.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] power_profile_id: uuid.UUID = GenPPBCEndInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] "power_profile_id" ] # type: ignore[assignment] diff --git a/src/s2python/ppbc/ppbc_power_profile_status.py b/src/s2python/ppbc/ppbc_power_profile_status.py index a43661f..5d7d1be 100644 --- a/src/s2python/ppbc/ppbc_power_profile_status.py +++ b/src/s2python/ppbc/ppbc_power_profile_status.py @@ -1,4 +1,5 @@ from typing import List +import uuid from s2python.generated.gen_s2 import ( PPBCPowerProfileStatus as GenPPBCPowerProfileStatus, @@ -19,6 +20,7 @@ class PPBCPowerProfileStatus(GenPPBCPowerProfileStatus, S2MessageComponent): model_config = GenPPBCPowerProfileStatus.model_config model_config["validate_assignment"] = True + message_id: uuid.UUID = GenPPBCPowerProfileStatus.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] sequence_container_status: List[PPBCPowerSequenceContainerStatus] = ( # type: ignore[reportIncompatibleVariableOverride] GenPPBCPowerProfileStatus.model_fields["sequence_container_status"] # type: ignore[assignment] ) diff --git a/src/s2python/ppbc/ppbc_start_interruption_instruction.py b/src/s2python/ppbc/ppbc_start_interruption_instruction.py index a9fdea6..42ccb92 100644 --- a/src/s2python/ppbc/ppbc_start_interruption_instruction.py +++ b/src/s2python/ppbc/ppbc_start_interruption_instruction.py @@ -16,6 +16,7 @@ class PPBCStartInterruptionInstruction(GenPPBCStartInterruptionInstruction, S2Me model_config["validate_assignment"] = True id: uuid.UUID = GenPPBCStartInterruptionInstruction.model_fields["id"] # type: ignore[assignment,reportIncompatibleVariableOverride] + message_id: uuid.UUID = GenPPBCStartInterruptionInstruction.model_fields["message_id"] # type: ignore[assignment,reportIncompatibleVariableOverride] power_profile_id: uuid.UUID = GenPPBCStartInterruptionInstruction.model_fields[ # type: ignore[reportIncompatibleVariableOverride] "power_profile_id" ] # type: ignore[assignment] diff --git a/src/s2python/validate_values_mixin.py b/src/s2python/validate_values_mixin.py index 2d4a644..7026235 100644 --- a/src/s2python/validate_values_mixin.py +++ b/src/s2python/validate_values_mixin.py @@ -43,7 +43,7 @@ def to_json(self) -> str: ) from e def to_dict(self) -> Dict[str, Any]: - return self.model_dump() + return self.model_dump(mode='json') @classmethod def from_json(cls, json_str: str) -> Self: From 38ed38c19c197d8066131dd740d06194bebb2405 Mon Sep 17 00:00:00 2001 From: Sebastiaan la Fleur Date: Thu, 31 Jul 2025 12:58:48 +0200 Subject: [PATCH 2/7] Write S2Message and S2MessageWithID as type aliases. --- src/s2python/message.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/s2python/message.py b/src/s2python/message.py index 78df8fd..0fca2be 100644 --- a/src/s2python/message.py +++ b/src/s2python/message.py @@ -1,5 +1,7 @@ from typing import Union +from typing_extensions import TypeAlias + from s2python.frbc import ( FRBCActuatorDescription, FRBCActuatorStatus, @@ -76,7 +78,7 @@ Transition, ) -S2MessageWithID = Union[ +S2MessageWithID: TypeAlias = Union[ DDBCAverageDemandRateForecast, DDBCInstruction, DDBCSystemDescription, @@ -113,7 +115,7 @@ PowerMeasurement, ] -S2Message = [ +S2Message: TypeAlias = Union[ S2MessageWithID, ReceptionStatus, ] From 210dd8e8073f3cb7da4e1fde131a4784b044365a Mon Sep 17 00:00:00 2001 From: Sebastiaan la Fleur Date: Thu, 31 Jul 2025 13:39:24 +0200 Subject: [PATCH 3/7] Ensure that setuptools-scm and typing-extensions are available. --- dev-requirements.txt | 16 ++++++++++------ pyproject.toml | 2 ++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index 6a82446..f38a575 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.8 +# This file is autogenerated by pip-compile with Python 3.9 # by the following command: # # pip-compile --extra=development --extra=docs --extra=testing --extra=ws --output-file=./dev-requirements.txt pyproject.toml @@ -65,6 +65,7 @@ imagesize==1.4.1 importlib-metadata==8.5.0 # via # build + # setuptools-scm # sphinx inflect==5.6.2 # via datamodel-code-generator @@ -99,6 +100,7 @@ packaging==24.2 # datamodel-code-generator # pyproject-api # pytest + # setuptools-scm # sphinx # tox pathspec==0.12.1 @@ -151,15 +153,15 @@ pytest-coverage==0.0 pytest-timer==1.0.0 # via s2-python (pyproject.toml) pytz==2025.1 - # via - # babel - # s2-python (pyproject.toml) + # via s2-python (pyproject.toml) pyyaml==6.0.2 # via # datamodel-code-generator # pre-commit requests==2.32.3 # via sphinx +setuptools-scm==8.3.1 + # via s2-python (pyproject.toml) six==1.17.0 # via sphinxcontrib-httpdomain snowballstemmer==2.2.0 @@ -208,6 +210,7 @@ tomli==2.2.1 # pylint # pyproject-api # pytest + # setuptools-scm # tox tomlkit==0.13.2 # via pylint @@ -217,7 +220,6 @@ types-pytz==2024.2.0.20241221 # via s2-python (pyproject.toml) typing-extensions==4.12.2 # via - # annotated-types # astroid # black # mypy @@ -225,6 +227,8 @@ typing-extensions==4.12.2 # pydantic-core # pylint # pyright + # s2-python (pyproject.toml) + # setuptools-scm # tox urllib3==2.2.3 # via requests @@ -238,7 +242,7 @@ wheel==0.45.1 # via pip-tools zipp==3.20.2 # via importlib-metadata -setuptools-scm==8.3.1 + # The following packages are considered to be unsafe in a requirements file: # pip # setuptools diff --git a/pyproject.toml b/pyproject.toml index 8cd822b..e546a09 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "pydantic>=2.8.2", "pytz", "click", + "typing-extensions", ] classifiers = [ "Development Status :: 4 - Beta", @@ -52,6 +53,7 @@ development = [ "datamodel-code-generator", "pre-commit", "tox", + "setuptools_scm", ] docs = [ "sphinx", From 4611cc3deee8f96f5ccf4b6a213a3b0d0bf40dce Mon Sep 17 00:00:00 2001 From: Sebastiaan la Fleur Date: Fri, 1 Aug 2025 11:37:56 +0200 Subject: [PATCH 4/7] to_dict should take a mode and add some unit tests. --- src/s2python/validate_values_mixin.py | 20 ++++++- tests/unit/message_test.py | 22 ++++++++ tests/unit/validate_values_mixing_test.py | 67 +++++++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tests/unit/validate_values_mixing_test.py diff --git a/src/s2python/validate_values_mixin.py b/src/s2python/validate_values_mixin.py index 7026235..d84c938 100644 --- a/src/s2python/validate_values_mixin.py +++ b/src/s2python/validate_values_mixin.py @@ -7,7 +7,7 @@ AbstractSet, Mapping, List, - Dict, + Dict, Literal, ) from typing_extensions import Self @@ -35,6 +35,10 @@ def __setattr__(self, name: str, value: Any) -> None: ) from e def to_json(self) -> str: + """Convert the S2 message or message component to a json string. + + :return: The json string. + """ try: return self.model_dump_json(by_alias=True, exclude_none=True) except (ValidationError, TypeError) as e: @@ -42,8 +46,18 @@ def to_json(self) -> str: type(self), self, "Pydantic raised a validation error.", ) from e - def to_dict(self) -> Dict[str, Any]: - return self.model_dump(mode='json') + def to_dict(self, mode: Literal['python', 'json']='python') -> Dict[str, Any]: + """Convert the S2 message or message component to a Python dictionary. + + Conversion happens according to https://docs.pydantic.dev/latest/concepts/conversion_table/#__tabbed_1_4 + in non-strict 'python' mode which is the default. Conversion happens according to + https://docs.pydantic.dev/latest/concepts/conversion_table/#__tabbed_1_2 in non-strict 'json' mode. + + :param mode: To convert to a dict with python datastructures or json-serializable datastructures. + :return: A dictionary with python datastructures when mode is set to 'python' and a json-serializable dict + when mode is set to 'json'. + """ + return self.model_dump(mode=mode) @classmethod def from_json(cls, json_str: str) -> Self: diff --git a/tests/unit/message_test.py b/tests/unit/message_test.py index 1d3c4a9..eac4a4f 100644 --- a/tests/unit/message_test.py +++ b/tests/unit/message_test.py @@ -3,9 +3,12 @@ import importlib import inspect import pkgutil +import uuid from typing import get_args from s2python import message +from s2python.common import ReceptionStatus, ReceptionStatusValues +from s2python.frbc import FRBCStorageStatus from s2python.validate_values_mixin import S2MessageComponent @@ -61,5 +64,24 @@ def test_import_s2_messages__pebc(self): def test_import_s2_messages__ppbc(self): self._test_import_s2_messages("s2python.ppbc") + def test_import_s2_messages__ombc(self): self._test_import_s2_messages("s2python.ombc") + + + def test__s2messagewithid__type_has_message_id_field(self): + # Arrange + message_with_id: message.S2MessageWithID = FRBCStorageStatus(message_id=uuid.uuid4(), + present_fill_level=4.0) + + # Act / Assert + self.assertIsInstance(message_with_id.message_id, uuid.UUID) + + def test__s2message__type_has_no_message_id_field(self): + # Arrange + message_without_id: message.S2Message = ReceptionStatus(subject_message_id=uuid.uuid4(), + status=ReceptionStatusValues.OK, + diagnostic_label='Hello!') + + # Act / Assert + self.assertFalse(hasattr(message_without_id, 'message_id')) diff --git a/tests/unit/validate_values_mixing_test.py b/tests/unit/validate_values_mixing_test.py new file mode 100644 index 0000000..7aa02f0 --- /dev/null +++ b/tests/unit/validate_values_mixing_test.py @@ -0,0 +1,67 @@ +import json +import unittest +import datetime +import uuid + +from s2python.validate_values_mixin import S2MessageComponent + + +class MockS2Message(S2MessageComponent): + some_uuid: uuid.UUID + some_str: str + some_float: float + some_datetime: datetime.datetime + some_timedelta: datetime.timedelta + + +def example_message() -> MockS2Message: + return MockS2Message(some_uuid=uuid.uuid4(), + some_str='asdaa', + some_float=3.14, + some_datetime=datetime.datetime.now(), + some_timedelta=datetime.timedelta(hours=1, minutes=1)) + + +class TestS2MessageComponent(unittest.TestCase): + def test__to_json__okay(self): + # Arrange + message = example_message() + + # Act + json_str = message.to_json() + + # Assert + message_json = json.loads(json_str) + self.assertEqual(message_json['some_uuid'], str(message.some_uuid)) + self.assertEqual(message_json['some_str'], message.some_str) + self.assertEqual(message_json['some_float'], message.some_float) + self.assertEqual(message_json['some_datetime'], message.some_datetime.isoformat()) + self.assertEqual(message_json['some_timedelta'], 'PT1H1M') + + def test__to_dict__okay_python(self): + # Arrange + message = example_message() + + # Act + message_dict = message.to_dict(mode='python') + + # Assert + self.assertEqual(message_dict['some_uuid'], message.some_uuid) + self.assertEqual(message_dict['some_str'], message.some_str) + self.assertEqual(message_dict['some_float'], message.some_float) + self.assertEqual(message_dict['some_datetime'], message.some_datetime) + self.assertEqual(message_dict['some_timedelta'], message.some_timedelta) + + def test__to_dict__okay_json(self): + # Arrange + message = example_message() + + # Act + message_dict = message.to_dict(mode='json') + + # Assert + self.assertEqual(message_dict['some_uuid'], str(message.some_uuid)) + self.assertEqual(message_dict['some_str'], message.some_str) + self.assertEqual(message_dict['some_float'], message.some_float) + self.assertEqual(message_dict['some_datetime'], message.some_datetime.isoformat()) + self.assertEqual(message_dict['some_timedelta'], 'PT1H1M') From 4cb9416eb56ffd288d62be15df06ac77471e2106 Mon Sep 17 00:00:00 2001 From: Sebastiaan la Fleur Date: Fri, 1 Aug 2025 11:43:30 +0200 Subject: [PATCH 5/7] json.dumps should work for to_dict if mode is set to json --- tests/unit/validate_values_mixing_test.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/validate_values_mixing_test.py b/tests/unit/validate_values_mixing_test.py index 7aa02f0..5fcac57 100644 --- a/tests/unit/validate_values_mixing_test.py +++ b/tests/unit/validate_values_mixing_test.py @@ -60,6 +60,7 @@ def test__to_dict__okay_json(self): message_dict = message.to_dict(mode='json') # Assert + json.dumps(message_dict) self.assertEqual(message_dict['some_uuid'], str(message.some_uuid)) self.assertEqual(message_dict['some_str'], message.some_str) self.assertEqual(message_dict['some_float'], message.some_float) From f7c87458ebcc5dd1ab195aeba8318a7aaa1e07bf Mon Sep 17 00:00:00 2001 From: Sebastiaan la Fleur Date: Mon, 11 Aug 2025 12:06:33 +0200 Subject: [PATCH 6/7] Split to_dict with json and python modes into separate functions. --- src/s2python/validate_values_mixin.py | 25 +++++++++++++++-------- tests/unit/validate_values_mixing_test.py | 8 ++++---- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/s2python/validate_values_mixin.py b/src/s2python/validate_values_mixin.py index d84c938..d067962 100644 --- a/src/s2python/validate_values_mixin.py +++ b/src/s2python/validate_values_mixin.py @@ -7,7 +7,7 @@ AbstractSet, Mapping, List, - Dict, Literal, + Dict, ) from typing_extensions import Self @@ -46,18 +46,25 @@ def to_json(self) -> str: type(self), self, "Pydantic raised a validation error.", ) from e - def to_dict(self, mode: Literal['python', 'json']='python') -> Dict[str, Any]: - """Convert the S2 message or message component to a Python dictionary. + def to_dict(self) -> Dict[str, Any]: + """Convert the S2 message or message component to a Python dictionary that contains Python-native structures.. Conversion happens according to https://docs.pydantic.dev/latest/concepts/conversion_table/#__tabbed_1_4 - in non-strict 'python' mode which is the default. Conversion happens according to - https://docs.pydantic.dev/latest/concepts/conversion_table/#__tabbed_1_2 in non-strict 'json' mode. + in non-strict 'python' mode.. - :param mode: To convert to a dict with python datastructures or json-serializable datastructures. - :return: A dictionary with python datastructures when mode is set to 'python' and a json-serializable dict - when mode is set to 'json'. + :return: A dictionary with python datastructures. """ - return self.model_dump(mode=mode) + return self.model_dump(mode='python') + + def to_json_dict(self) -> Dict[str, Any]: + """Convert the S2 message or message component to a Python dictionary which is json serializable. + + Conversion happens according to https://docs.pydantic.dev/latest/concepts/conversion_table/#__tabbed_1_2 in + non-strict 'json' mode. + + :return: A dictionary with json-serializable values. + """ + return self.model_dump(mode='json') @classmethod def from_json(cls, json_str: str) -> Self: diff --git a/tests/unit/validate_values_mixing_test.py b/tests/unit/validate_values_mixing_test.py index 5fcac57..110b18d 100644 --- a/tests/unit/validate_values_mixing_test.py +++ b/tests/unit/validate_values_mixing_test.py @@ -38,12 +38,12 @@ def test__to_json__okay(self): self.assertEqual(message_json['some_datetime'], message.some_datetime.isoformat()) self.assertEqual(message_json['some_timedelta'], 'PT1H1M') - def test__to_dict__okay_python(self): + def test__to_dict__okay(self): # Arrange message = example_message() # Act - message_dict = message.to_dict(mode='python') + message_dict = message.to_dict() # Assert self.assertEqual(message_dict['some_uuid'], message.some_uuid) @@ -52,12 +52,12 @@ def test__to_dict__okay_python(self): self.assertEqual(message_dict['some_datetime'], message.some_datetime) self.assertEqual(message_dict['some_timedelta'], message.some_timedelta) - def test__to_dict__okay_json(self): + def test__to_json_dict__okay(self): # Arrange message = example_message() # Act - message_dict = message.to_dict(mode='json') + message_dict = message.to_json_dict() # Assert json.dumps(message_dict) From 99c4ba05a687e93f3e7296baa657b78fc39581c4 Mon Sep 17 00:00:00 2001 From: Sebastiaan la Fleur Date: Mon, 11 Aug 2025 12:07:28 +0200 Subject: [PATCH 7/7] Fix typo. --- src/s2python/validate_values_mixin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/s2python/validate_values_mixin.py b/src/s2python/validate_values_mixin.py index d067962..f140205 100644 --- a/src/s2python/validate_values_mixin.py +++ b/src/s2python/validate_values_mixin.py @@ -50,7 +50,7 @@ def to_dict(self) -> Dict[str, Any]: """Convert the S2 message or message component to a Python dictionary that contains Python-native structures.. Conversion happens according to https://docs.pydantic.dev/latest/concepts/conversion_table/#__tabbed_1_4 - in non-strict 'python' mode.. + in non-strict 'python' mode. :return: A dictionary with python datastructures. """