Skip to content

Commit 71312d7

Browse files
committed
Refactor MessageResponse to BaseModel
- Add MessagePayload/MessageResponse models with coercion - Keep response shape while tightening serialization logic - Guard empty payload processing and update capture parsing - Add tests for BaseModel payload coercion
1 parent ac01626 commit 71312d7

5 files changed

Lines changed: 1121 additions & 67 deletions

File tree

src/pypnm/api/routes/advance/common/capture_service.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# SPDX-License-Identifier: Apache-2.0
2-
# Copyright (c) 2025 Maurice Garcia
2+
# Copyright (c) 2025-2026 Maurice Garcia
33
from __future__ import annotations
44

55
import asyncio
@@ -288,7 +288,7 @@ def _process_captures(self, msg_rsp: MessageResponse) -> list[CaptureSample]:
288288
samples: list[CaptureSample] = []
289289
for idx, entry in enumerate(payload):
290290
try:
291-
status_str, msg_type, body = MessageResponse.get_payload_msg(entry) # type: ignore
291+
status_str, msg_type, body = MessageResponse.get_payload_msg(entry)
292292

293293
except Exception as exc:
294294
err = f"Failed to parse payload entry {idx}: {exc}"
@@ -381,4 +381,3 @@ async def _capture_message_response(self) -> MessageResponse:
381381
``status == ServiceStatusCode.SKIP_MESSAGE_RESPONSE``.
382382
"""
383383
...
384-

src/pypnm/api/routes/common/extended/common_messaging_service.py

Lines changed: 95 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
# SPDX-License-Identifier: Apache-2.0
2-
# Copyright (c) 2025 Maurice Garcia
2+
# Copyright (c) 2025-2026 Maurice Garcia
33

44
from __future__ import annotations
55

66
import json
77
from enum import Enum
88
import logging
9-
from typing import Any
9+
10+
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
1011

1112
from pypnm.api.routes.common.service.status_codes import ServiceStatusCode
1213
from pypnm.config.pnm_config_manager import SystemConfigSettings
@@ -23,103 +24,132 @@ class MessageResponseType(Enum):
2324
PNM_FILE_SESSION = 2
2425
SNMP_DATA_RTN_SPEC_ANALYSIS = 10
2526

26-
class MessageResponse:
27+
class MessagePayload(BaseModel):
2728
"""
28-
Represents a structured response with a status and optional data payload.
29-
30-
Attributes:
31-
status (ServiceStatusCode): Status of the message.
32-
payload (Optional[Any]): Associated payload (list, dict, etc.).
33-
34-
Example:
35-
36-
{
37-
"status":"SUCCESS",
38-
"payload":[
39-
{
40-
"status":"SUCCESS",
41-
"message_type":"PNM_FILE_TRANSACTION",
42-
"message":{
43-
"transaction_id":"275de83146e904d7",
44-
"filename":"ds_ofdm_rxmer_per_subcar_00:50:f1:12:e2:63_954000000_1746501260.bin"
45-
}
46-
}
47-
]
48-
}
49-
29+
Typed payload entry for MessageResponse.
5030
"""
31+
model_config = ConfigDict(extra="allow")
5132

52-
def __init__(self, status: ServiceStatusCode, payload: Any | None = None) -> None:
53-
"""
54-
Initializes a MessageResponse instance.
33+
status: str = Field(..., description="Status for this payload entry.")
34+
message_type: str | None = Field(None, description="Message type identifier.")
35+
message: object | None = Field(None, description="Message-specific content.")
5536

56-
Args:
57-
status (ServiceStatusCode): Status of the message.
58-
payload (Optional[Any]): Optional message payload.
37+
def as_dict(self) -> dict[str, object]:
5938
"""
60-
self.status:ServiceStatusCode = status
61-
self.payload:Any | None = payload
39+
Return this payload as a dictionary, preserving extra fields.
40+
"""
41+
return self.model_dump()
42+
6243

63-
def get(self) -> dict[str, Any]:
44+
class MessageResponse(BaseModel):
45+
"""
46+
Represents a structured response with a status and optional data payload.
47+
"""
48+
model_config = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True)
49+
50+
status: ServiceStatusCode = Field(..., description="Status of the message.")
51+
payload: list[MessagePayload] | None = Field(None, description="Associated payload entries.")
52+
53+
def __init__(self, status: ServiceStatusCode, payload: list[MessagePayload] | list[dict[str, object]] | None = None) -> None:
54+
super().__init__(status=status, payload=payload)
55+
56+
@field_validator("payload", mode="before")
57+
@classmethod
58+
def _coerce_payload(cls, value: object) -> list[MessagePayload] | None:
59+
if value is None:
60+
return None
61+
if isinstance(value, list):
62+
items: list[MessagePayload] = []
63+
for entry in value:
64+
if isinstance(entry, MessagePayload):
65+
items.append(entry)
66+
continue
67+
if isinstance(entry, dict):
68+
items.append(MessagePayload(**entry))
69+
continue
70+
items.append(MessagePayload(status="UNKNOWN", message=entry))
71+
return items
72+
raise ValueError("payload must be a list or None")
73+
74+
@field_serializer("status")
75+
def _serialize_status(self, status: ServiceStatusCode) -> str:
76+
return status.name
77+
78+
def get(self) -> dict[str, object]:
6479
"""
6580
Serializes the message response to a dictionary.
6681
6782
Returns:
68-
Dict[str, Any]: Dictionary with 'status' and 'data'.
83+
Dict[str, object]: Dictionary with 'status' and 'payload'.
6984
"""
7085
return {
7186
"status": self.status.name,
72-
"payload": self.payload
87+
"payload": self._payload_as_dict_list(),
7388
}
7489

7590
def __repr__(self) -> str:
76-
return json.dumps({
77-
"status": self.status.name,
78-
"payload": self.payload
79-
})
91+
return json.dumps(self.get())
8092

8193
def __str__(self) -> str:
8294
return self.__repr__()
8395

84-
def get_payload_msg(payload_element: dict[str, Any]) -> tuple[str, str, Any]:
96+
def get_payload_msg(payload_element: MessagePayload | dict[str, object]) -> tuple[str, str, object | None]:
8597
"""
8698
Extracts 'status', 'message_type', and 'message' from a payload element.
8799
88100
Args:
89-
payload_element (Dict[str, Any]): The payload dictionary.
101+
payload_element (MessagePayload | Dict[str, object]): The payload element.
90102
91103
Returns:
92-
Tuple[str, str, Any]: A tuple containing the status, message type, and message content.
104+
Tuple[str, str, object | None]: A tuple containing the status, message type, and message content.
93105
"""
94-
status = payload_element.get("status", "UNKNOWN")
95-
message_type = payload_element.get("message_type", "UNKNOWN")
96-
message = payload_element.get("message", None)
106+
if isinstance(payload_element, MessagePayload):
107+
payload_dict = payload_element.as_dict()
108+
else:
109+
payload_dict = payload_element
110+
status = str(payload_dict.get("status", "UNKNOWN"))
111+
message_type = str(payload_dict.get("message_type", "UNKNOWN"))
112+
message = payload_dict.get("message", None)
97113
return status, message_type, message
98114

99-
def payload_to_dict(self, key: int | str = "data") -> dict[int | str, Any]:
115+
def payload_to_dict(self, key: int | str = "data") -> dict[int | str, object]:
100116
"""
101117
Wraps the internal payload in a dictionary under the specified key.
102118
103119
Args:
104120
key (int | str): The key under which the payload will be stored. Defaults to "data".
105121
106122
Returns:
107-
Dict[Any, Any]: A dictionary containing the payload under the given key.
123+
Dict[int | str, object]: A dictionary containing the payload under the given key.
108124
"""
109-
return {key: self.payload}
125+
return {key: self._payload_as_dict_list()}
110126

111-
def log_payload(self, filename_prefix:str = "") -> None:
127+
def log_payload(self, filename_prefix: str = "") -> None:
112128
"""
113129
Logs the payload content for debugging purposes.
114130
"""
115-
prefix:str = ""
131+
prefix: str = ""
116132
if filename_prefix:
117133
prefix = f'{filename_prefix}_'
118134

119135
LogFile.write(f'{prefix}payload_{Generate.time_stamp(TimeUnit.MILLISECONDS)}.msgrsp',
120136
self.payload_to_dict(),
121137
log_dir = SystemConfigSettings.message_response_dir())
122138

139+
def _payload_as_dict_list(self) -> list[dict[str, object]] | None:
140+
if self.payload is None:
141+
return None
142+
payload_list: list[dict[str, object]] = []
143+
for entry in self.payload:
144+
if isinstance(entry, MessagePayload):
145+
payload_list.append(entry.as_dict())
146+
continue
147+
if isinstance(entry, dict):
148+
payload_list.append(dict(entry))
149+
continue
150+
payload_list.append({"status": "UNKNOWN", "message": entry})
151+
return payload_list
152+
123153

124154
class CommonMessagingService:
125155
"""
@@ -129,7 +159,7 @@ class CommonMessagingService:
129159
batch operations, chained service calls, and aggregating results for client APIs.
130160
131161
Attributes:
132-
_messages (List[Tuple[ServiceStatusCode, Dict[str, Any]]]): Queue of messages.
162+
_messages (List[Tuple[ServiceStatusCode, Dict[str, object]]]): Queue of messages.
133163
_last_non_success_status (ServiceStatusCode): Most recent non-success status seen.
134164
"""
135165

@@ -138,16 +168,16 @@ def __init__(self) -> None:
138168
Initializes an empty messaging service instance.
139169
"""
140170
self.logger = logging.getLogger(self.__class__.__name__)
141-
self._messages: list[tuple[ServiceStatusCode, dict[str, Any]]] = []
171+
self._messages: list[tuple[ServiceStatusCode, dict[str, object]]] = []
142172
self._last_non_success_status = ServiceStatusCode.SUCCESS
143173

144-
def build_msg(self, status: ServiceStatusCode, payload: dict[str, Any] | None = None) -> None:
174+
def build_msg(self, status: ServiceStatusCode, payload: dict[str, object] | None = None) -> None:
145175
"""
146176
Queues a new message with status and optional data.
147177
148178
Args:
149179
status (ServiceStatusCode): Message status.
150-
payload (Optional[Dict[str, Any]]): Associated data for the message.
180+
payload (Optional[Dict[str, object]]): Associated data for the message.
151181
152182
Returns:
153183
bool: Always returns True after storing the message.
@@ -175,17 +205,18 @@ def send_msg(self) -> MessageResponse:
175205
)
176206

177207
combined_data = [
178-
{
179-
"status": status.name,
180-
**data
181-
} for status, data in self._messages
208+
MessagePayload(
209+
status=status.name,
210+
**data,
211+
)
212+
for status, data in self._messages
182213
]
183214

184215
self._messages.clear()
185216

186217
return MessageResponse(final_status, combined_data)
187218

188-
def build_send_msg(self, status: ServiceStatusCode, data: dict[str, Any] | None = None) -> MessageResponse:
219+
def build_send_msg(self, status: ServiceStatusCode, data: dict[str, object] | None = None) -> MessageResponse:
189220
"""
190221
Builds and immediately sends a single message.
191222
@@ -220,9 +251,9 @@ def build_transaction_msg(self, transaction_id: TransactionId, filename: FileNam
220251
}
221252
})
222253

223-
def build_transaction_msg_extension(self, transaction_id: TransactionId,
254+
def build_transaction_msg_extension(self, transaction_id: TransactionId,
224255
filename: FileNameStr,
225-
extension: dict[Any, Any],
256+
extension: dict[str, object],
226257
status: ServiceStatusCode = ServiceStatusCode.SUCCESS) -> None:
227258
"""
228259
Adds a transaction message with an ID and filename to the message queue.
@@ -268,15 +299,15 @@ def build_session_msg( self,session_id: str,transaction_ids: list[TransactionId]
268299
},
269300
)
270301

271-
def get_first_of_type(self, msg_type: MessageResponseType) -> dict[str, Any] | None:
302+
def get_first_of_type(self, msg_type: MessageResponseType) -> dict[str, object] | None:
272303
"""
273304
Retrieves the first message of a specified type, if available.
274305
275306
Args:
276307
msg_type (MessageResponseType): The type to look for.
277308
278309
Returns:
279-
Optional[Dict[str, Any]]: The first message of the given type, or None.
310+
Optional[Dict[str, object]]: The first message of the given type, or None.
280311
"""
281312
for _, data in self._messages:
282313
if data.get("message_type") == msg_type.name:

src/pypnm/api/routes/common/extended/common_process_service.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ def process(self) -> MessageResponse:
6464
MessageResponse: A success message if all payloads are processed,
6565
or an error message if a transaction record is missing.
6666
"""
67+
if not self._msg_rsp.payload:
68+
self.logger.warning("Message response payload is empty.")
69+
return self.send_msg()
70+
6771
for payload in self._msg_rsp.payload:
6872
status, message_type, message = MessageResponse.get_payload_msg(payload)
6973

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Copyright (c) 2026 Maurice Garcia
3+
4+
from __future__ import annotations
5+
6+
from pypnm.api.routes.common.extended.common_messaging_service import MessageResponse
7+
from pypnm.api.routes.common.service.status_codes import ServiceStatusCode
8+
9+
10+
def test_message_response_payload_coercion() -> None:
11+
payload = [
12+
{
13+
"status": ServiceStatusCode.SUCCESS.name,
14+
"message_type": "PNM_FILE_TRANSACTION",
15+
"message": {
16+
"transaction_id": "abc123",
17+
"filename": "capture.bin",
18+
},
19+
"extra_field": "extra",
20+
},
21+
]
22+
23+
msg_rsp = MessageResponse(ServiceStatusCode.SUCCESS, payload=payload)
24+
25+
assert msg_rsp.status == ServiceStatusCode.SUCCESS
26+
assert msg_rsp.payload is not None
27+
assert msg_rsp.payload[0].status == ServiceStatusCode.SUCCESS.name
28+
29+
payload_dict = msg_rsp.payload_to_dict()
30+
assert payload_dict["data"][0]["message_type"] == "PNM_FILE_TRANSACTION"
31+
assert payload_dict["data"][0]["extra_field"] == "extra"
32+
33+
msg_dict = msg_rsp.get()
34+
assert msg_dict["status"] == ServiceStatusCode.SUCCESS.name

0 commit comments

Comments
 (0)