mms-client 1.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,513 @@
1
+ """Contains objects for serialization and deserialization of MMS data."""
2
+
3
+ from functools import lru_cache
4
+ from io import BytesIO
5
+ from pathlib import Path
6
+ from typing import Dict
7
+ from typing import Optional
8
+ from typing import Tuple
9
+ from typing import Type
10
+ from typing import Union
11
+ from typing import get_args
12
+ from typing import get_origin
13
+
14
+ from lxml.etree import XMLSchema
15
+ from lxml.etree import _Element as Element
16
+ from lxml.etree import parse
17
+ from pydantic_xml import element
18
+
19
+ from mms_client.types.base import E
20
+ from mms_client.types.base import Messages
21
+ from mms_client.types.base import MultiResponse
22
+ from mms_client.types.base import P
23
+ from mms_client.types.base import Payload
24
+ from mms_client.types.base import PayloadBase
25
+ from mms_client.types.base import Response
26
+ from mms_client.types.base import ResponseCommon
27
+ from mms_client.types.base import ResponseData
28
+ from mms_client.types.base import SchemaType
29
+
30
+ # Directory containing all our XML schemas
31
+ XSD_DIR = Path(__file__).parent.parent / "schemas" / "xsd"
32
+
33
+
34
+ class Serializer:
35
+ """Contains methods for serializing and deserializing MMS data."""
36
+
37
+ def __init__(self, xsd: SchemaType, payload_key: str):
38
+ """Create a new payload configuration with the given XSD schema, payload key, and interface type.
39
+
40
+ Arguments:
41
+ xsd (SchemaType): The XSD schema to use for validation.
42
+ payload_key (str): The key to use for the payload in the request.
43
+ interface (InterfaceType): The type of interface to use for the request.
44
+ """
45
+ # Save the configuration for later use
46
+ self._xsd = xsd
47
+ self._payload_key = payload_key
48
+
49
+ # Get a reference to the XSD file so we can use it for validation
50
+ with open(XSD_DIR / self._xsd.value, "rb") as f:
51
+ self._schema = XMLSchema(parse(f))
52
+
53
+ def serialize(self, request_envelope: E, request_data: P) -> bytes:
54
+ """Serialize the envelope and data to a byte string for sending to the MMS server.
55
+
56
+ Arguments:
57
+ request_envelope (Envelope): The envelope to be serialized.
58
+ request_data (Payload): The data to be serialized.
59
+
60
+ Returns: A byte string containing the XML-formatted data to be sent to the MMS server.
61
+ """
62
+ # First, create our payload class from the payload and data types
63
+ payload_cls = _create_request_payload_type(
64
+ self._payload_key,
65
+ type(request_envelope), # type: ignore[arg-type]
66
+ type(request_data), # type: ignore[arg-type]
67
+ )
68
+
69
+ # Next, inject the payload and data into the payload class
70
+ # Note: this returns a type that inherits from PayloadBase and the arguments provided to the initializer
71
+ # here are correct, but mypy thinks they are incorrect because it doesn't understand the the inherited type
72
+ payload = payload_cls(request_envelope, request_data, self._xsd.value) # type: ignore[call-arg, misc]
73
+
74
+ # Finally, convert the payload to XML and return it
75
+ # Note: we provided the encoding here so this will return bytes, not a string
76
+ return payload.to_xml(skip_empty=True, encoding="utf-8", xml_declaration=True) # type: ignore[return-value]
77
+
78
+ def deserialize(self, data: bytes, envelope_type: Type[E], data_type: Type[P]) -> Response[E, P]:
79
+ """Deserialize the data to a response object.
80
+
81
+ Arguments:
82
+ data (bytes): The raw data to be deserialized.
83
+ envelope_type (Type[Envelope]): The type of envelope to be constructed.
84
+ data_type (Type[Payload]): The type of data to be constructed.
85
+
86
+ Returns: A response object containing the envelope and data extracted from the raw data.
87
+ """
88
+ tree = self._from_xml(data)
89
+ return self._from_tree(tree, envelope_type, data_type)
90
+
91
+ def deserialize_multi(self, data: bytes, envelope_type: Type[E], data_type: Type[P]) -> MultiResponse[E, P]:
92
+ """Deserialize the data to a multi-response object.
93
+
94
+ Arguments:
95
+ data (bytes): The raw data to be deserialized.
96
+ envelope_type (Type[Envelope]): The type of envelope to be constructed.
97
+ data_type (Type[Payload]): The type of data to be constructed.
98
+
99
+ Returns: A multi-response object containing the envelope and data extracted from the raw data.
100
+ """
101
+ tree = self._from_xml(data)
102
+ return self._from_tree_multi(tree, envelope_type, data_type)
103
+
104
+ def _from_tree(self, raw: Element, envelope_type: Type[E], data_type: Type[P]) -> Response[E, P]:
105
+ """Convert the raw data to a response object.
106
+
107
+ Arguments:
108
+ raw (Element): The raw data to be converted.
109
+ envelope_type (Type[Envelope]): The type of envelope to be constructed.
110
+ data_type (Type[Payload]): The type of data to be constructed.
111
+
112
+ Returns: A response object containing the envelope and data extracted from the raw data.
113
+ """
114
+ # First, attempt to extract the response from the raw data; if the key isn't found then we'll raise an error.
115
+ # Otherwise, we'll attempt to construct the response from the raw data.
116
+ if self._payload_key != raw.tag:
117
+ raise ValueError(f"Expected payload key '{self._payload_key}' not found in response")
118
+ cls: Response[E, P] = _create_response_payload_type( # type: ignore[assignment]
119
+ self._payload_key,
120
+ envelope_type, # type: ignore[arg-type]
121
+ data_type, # type: ignore[arg-type]
122
+ False,
123
+ )
124
+ resp = cls.from_xml_tree(raw) # type: ignore[arg-type]
125
+
126
+ # Next, attempt to extract the envelope and data from within the response
127
+ resp.envelope, resp.envelope_validation, envelope_node = self._from_tree_envelope(raw, envelope_type)
128
+
129
+ # Now, verify that the response doesn't contain an unexpected data type and then retrieve the payload data
130
+ # from within the envelope
131
+ self._verify_tree_data_tag(envelope_node, data_type)
132
+ resp.payload = self._from_tree_data(envelope_node.find(data_type.__name__), data_type)
133
+
134
+ # Finally, attempt to extract the messages from within the payload
135
+ resp.messages = self._from_tree_messages(raw, envelope_type, data_type, self._payload_key, False)
136
+
137
+ # Return the response
138
+ return resp
139
+
140
+ def _from_tree_multi(self, raw: Element, envelope_type: Type[E], data_type: Type[P]) -> MultiResponse[E, P]:
141
+ """Convert the raw data to a multi-response object.
142
+
143
+ Arguments:
144
+ raw (Element): The raw data to be converted.
145
+ envelope_type (Type[Envelope]): The type of envelope to be constructed.
146
+ data_type (Type[Payload]): The type of data to be constructed.
147
+
148
+ Returns: A multi-response object containing the envelope and data extracted from the raw data.
149
+ """
150
+ # First, attempt to extract the response from the raw data; if the key isn't found then we'll raise an error.
151
+ # Otherwise, we'll attempt to construct the response from the raw data.
152
+ if self._payload_key != raw.tag:
153
+ raise ValueError(f"Expected payload key '{self._payload_key}' not found in response")
154
+ cls: MultiResponse[E, P] = _create_response_payload_type( # type: ignore[assignment]
155
+ self._payload_key,
156
+ envelope_type, # type: ignore[arg-type]
157
+ data_type, # type: ignore[arg-type]
158
+ True,
159
+ )
160
+ resp = cls.from_xml_tree(raw) # type: ignore[arg-type]
161
+
162
+ # Next, attempt to extract the envelope from the response
163
+ resp.envelope, resp.envelope_validation, env_node = self._from_tree_envelope(raw, envelope_type)
164
+
165
+ # Now, verify that the response doesn't contain an unexpected data type and then retrieve the payload data
166
+ # from within the envelope
167
+ # Note: apparently, mypy doesn't know about setter-getter properties either...
168
+ self._verify_tree_data_tag(env_node, data_type)
169
+ resp.payload = [
170
+ self._from_tree_data(item, data_type) for item in env_node.findall(data_type.__name__) # type: ignore[misc]
171
+ ]
172
+
173
+ # Finally, attempt to extract the messages from within the payload
174
+ resp.messages = self._from_tree_messages(raw, envelope_type, data_type, self._payload_key, True)
175
+
176
+ # Return the response
177
+ return resp
178
+
179
+ def _from_tree_envelope(self, raw: Element, envelope_type: Type[E]) -> Tuple[E, ResponseCommon, Element]:
180
+ """Attempt to extract the envelope from within the response.
181
+
182
+ Arguments:
183
+ raw (Element): The raw data to be converted.
184
+ envelope_type (Type[Envelope]): The type of envelope to be constructed.
185
+
186
+ Returns:
187
+ Envelope: The request payload constructed from the raw data.
188
+ ResponseCommon: The validation information for the request payload.
189
+ """
190
+ # First, attempt to extract the envelope from within the response; if the key isn't found then we'll raise an
191
+ # exception to indicate that the envelope wasn't found.
192
+ envelope_node = raw.find(envelope_type.__name__)
193
+ if envelope_node is None:
194
+ raise ValueError(f"Expected envelope type '{envelope_type.__name__}' not found in response")
195
+
196
+ # Next, create a new envelope type that contains the envelope type with the appropriate XML tag. We have to do
197
+ # this because the envelope type doesn't include the ResponseCommon fields, and the tag doesn't match
198
+ # "ResponseCommon", so parsing will fail if we try to separate the envelope and common fields.
199
+ common_cls = _create_response_common_type(envelope_type) # type: ignore[arg-type]
200
+
201
+ # Finally, parse and return the envelope data and validation information
202
+ return (
203
+ envelope_type.from_xml_tree(envelope_node), # type: ignore[arg-type]
204
+ common_cls.from_xml_tree(envelope_node), # type: ignore[arg-type]
205
+ envelope_node,
206
+ )
207
+
208
+ def _verify_tree_data_tag(self, raw: Element, data_type: Type[P]) -> None:
209
+ """Verify that no types other than the expected data type are present in the response.
210
+
211
+ Arguments:
212
+ raw (Element): The raw data to be converted.
213
+ data_type (Type[Payload]): The type of data to be constructed.
214
+
215
+ Raises:
216
+ ValueError: If the expected data type is not found in the response.
217
+ """
218
+ data_tags = set(node.tag for node in raw)
219
+ if not data_tags.issubset([data_type.__name__, "Messages"]):
220
+ raise ValueError(f"Expected data type '{data_type.__name__}' not found in response")
221
+
222
+ def _from_tree_data(self, raw: Optional[Element], data_type: Type[P]) -> Optional[ResponseData[P]]:
223
+ """Attempt to extract the data from within the payload.
224
+
225
+ Arguments:
226
+ raw (Element): The raw data to be converted.
227
+ data_type (Type[BaseModel]): The type of data to be constructed.
228
+
229
+ Returns:
230
+ BaseModel: The data constructed from the raw data.
231
+ ResponseCommon: The validation information for the data extracted from the response.
232
+ """
233
+ # First, verify that the data type is present in the response; if it isn't then we'll raise an exception to
234
+ # indicate that the data wasn't found.
235
+ if raw is None:
236
+ return None
237
+
238
+ # Next, create a new data type that contains the data type with the appropriate XML tag. We have to do this
239
+ common_cls = _create_response_common_type(data_type) # type: ignore[arg-type]
240
+
241
+ # Finally, parse and return the data and validation information
242
+ return ResponseData[P](
243
+ data_type.from_xml_tree(raw), # type: ignore[arg-type]
244
+ common_cls.from_xml_tree(raw), # type: ignore[arg-type]
245
+ )
246
+
247
+ def _from_tree_messages(
248
+ self, raw: Element, envelope_type: Type[E], current_type: Type[P], root: str, multi: bool
249
+ ) -> Dict[str, Messages]:
250
+ """Attempt to extract the messages from within the payload.
251
+
252
+ Note that this method is called recursively to handle nested payloads and data types. It does not iterate over
253
+ the XML tree to determine the structure of the response; instead, it uses the type annotations to determine the
254
+ structure of the response.
255
+
256
+ Arguments:
257
+ raw (Element): The raw data to be converted.
258
+ envelope_type (Type[Envelope]): The type of envelope being constructed.
259
+ current_type (Type[Payload]): The type of data being constructed.
260
+ root (str): The root of the dictionary, used to create the key for the messages.
261
+ multi (bool): Whether we're processing a list of nodes or a single node. If called with the
262
+ payload root, this value will determine whether we're processing a multi-
263
+ response or a single response.
264
+ """
265
+ # First, find the Messages node in the raw data
266
+ message_node = raw.find("Messages")
267
+
268
+ # Next, create our dictionary of messages and attempt to extract the messages from the raw data at the current
269
+ # level of the response and set it to the root key
270
+ messages = {}
271
+ if message_node is not None:
272
+ messages[root] = Messages.from_xml_tree(message_node) # type: ignore[arg-type]
273
+
274
+ # Next, we need to call this method recursively, depending on where we are in the response object. If we are
275
+ # at the root of the response, we need to call this method for the envelope type. If we are at the envelope
276
+ # type, we need to call this method for the data type. If we are at the data type, we need to call this method
277
+ # for each field in the data type that is a Payload type.
278
+ if root == self._payload_key:
279
+ messages.update(
280
+ self._from_tree_messages(
281
+ _find_or_fail(raw, envelope_type.__name__),
282
+ envelope_type,
283
+ current_type,
284
+ f"{root}.{envelope_type.__name__}",
285
+ multi,
286
+ )
287
+ )
288
+ elif root.endswith(envelope_type.__name__):
289
+ messages.update(
290
+ self._from_tree_messages_inner(raw, envelope_type, current_type, root, current_type.__name__, multi)
291
+ )
292
+ else:
293
+ # Iterate over each field on the current type...
294
+ for name, field in current_type.model_fields.items():
295
+ print(f"Checking field {name} with type {field.annotation}...")
296
+
297
+ # First, get the arguments and origin of the field's annotation
298
+ args = get_args(field.annotation)
299
+ origin = get_origin(field.annotation)
300
+ has_args = len(args) > 0
301
+
302
+ # Next, check if the annotation is a subclass of Payload or else if it's a collection of Payload. If
303
+ # neither of these is the case, we can skip this field.
304
+ # Note: all our fields are annotated so there's no need to check if they're not
305
+ if not (
306
+ (has_args and issubclass(args[0], Payload))
307
+ or (not has_args and issubclass(field.annotation, Payload)) # type: ignore[arg-type]
308
+ ):
309
+ continue
310
+
311
+ # Finally, call this method recursively for the field and update the messages with the results
312
+ # Note: All our fields are annotated as XmlEntityInfo, so they have the "path" attribute
313
+ messages.update(
314
+ self._from_tree_messages_inner(
315
+ raw,
316
+ envelope_type,
317
+ args[0],
318
+ root,
319
+ field.path, # type: ignore[attr-defined]
320
+ origin is list,
321
+ )
322
+ )
323
+
324
+ # Finally, return our dictionary of messages
325
+ return messages
326
+
327
+ def _from_tree_messages_inner(
328
+ self, raw: Element, envelope_type: Type[E], current_type: Type[P], root: str, tag: str, multi: bool
329
+ ) -> Dict[str, Messages]:
330
+ """Attempt to extract the messages from within the payload at the current level.
331
+
332
+ Arguments:
333
+ raw (Element): The raw data to be converted.
334
+ envelope_type (Type[Envelope]): The type of envelope being constructed.
335
+ current_type (Type[Payload]): The type of data being constructed.
336
+ root (str): The root of the dictionary, used to create the key for the messages.
337
+ tag (str): The tag of the current node being processed.
338
+ multi (bool): If True, the payload will be a multi-response; otherwise, it will be a single
339
+ response.
340
+
341
+ Returns: A dictionary mapping messages to where they were found in the response.
342
+ """
343
+ # Construct the new root from the existing root and the tag
344
+ path_base = f"{root}.{tag}"
345
+
346
+ # If we are processing a list, we need to iterate over each node and call this method recursively. Otherwise,
347
+ # we can call this method recursively for the node and update the messages with the results.
348
+ if multi:
349
+ # Attempt to find all the nodes with the given tag; if we don't find any then we'll return empty.
350
+ nodes = raw.findall(tag)
351
+ if not nodes:
352
+ return {}
353
+
354
+ # Otherwise, we'll call this method recursively for each node and update the messages with the results.
355
+ messages = {}
356
+ for i, node in enumerate(nodes):
357
+ messages.update(self._from_tree_messages(node, envelope_type, current_type, f"{path_base}[{i}]", True))
358
+ return messages
359
+
360
+ # If we reached this point then we are processing a single item so find the associated
361
+ child = raw.find(tag)
362
+ return {} if child is None else self._from_tree_messages(child, envelope_type, current_type, path_base, False)
363
+
364
+ def _from_xml(self, data: bytes) -> Element:
365
+ """Parse the XML file, returning the resulting XML tree.
366
+
367
+ Arguments:
368
+ data: The raw XML data to be parsed.
369
+
370
+ Returns: A parsed and validated XML tree containing the data.
371
+ """
372
+ # First, get a file-like reference to the data
373
+ dat_file = BytesIO(data)
374
+
375
+ # Next, parse the XML data into an XML element tree
376
+ doc = parse(dat_file)
377
+
378
+ # Now, verify that the XML data is valid according to the schema
379
+ self._schema.assertValid(doc)
380
+
381
+ # Finally, return the results
382
+ return doc.getroot()
383
+
384
+
385
+ # Create the response payload type
386
+ PayloadType = Type[Union[Response[E, P], MultiResponse[E, P]]]
387
+
388
+
389
+ @lru_cache(maxsize=None)
390
+ def _create_response_payload_type(key: str, envelope_type: Type[E], data_type: Type[P], multi: bool) -> PayloadType:
391
+ """Create a new payload type for the given envelope and data types.
392
+
393
+ This method is intended to save us the overhead of writing a new class for each payload type. Instead, we can
394
+ create a new class at runtime that contains the envelope and data types, and use that for deserialization.
395
+
396
+ Note that this method has been LRU-cached because the operation of creating a new class from data types at
397
+ runtime involves lots of reflection and is quite slow. By caching the results, we can avoid the overhead of
398
+ creating new classes every time we need to deserialize data.
399
+
400
+ Arguments:
401
+ key (str): The tag to use for the parent element of the payload.
402
+ envelope_type (Type[Envelope]): The type of envelope to be constructed.
403
+ data_type (Type[Payload]): The type of data to be constructed.
404
+ multi (bool): If True, the payload will be a multi-response; otherwise, it will be a single
405
+ response.
406
+
407
+ Returns: The base response type that will be used for deserialization.
408
+ """
409
+ # First, create our base response type depending on whether we are creating a multi-response or not
410
+ base_type: PayloadType = Response[envelope_type, data_type] # type: ignore[valid-type]
411
+ if multi:
412
+ base_type = MultiResponse[envelope_type, data_type] # type: ignore[valid-type]
413
+
414
+ # Next, create a new payload type that contains the envelope and data types with the appropriate XML tag
415
+ class RSPayload(base_type, tag=key): # type: ignore[call-arg, valid-type, misc]
416
+ """Wrapper for the response payload type that will be used for serialization."""
417
+
418
+ # Finally, return the payload type so we can instantiate it
419
+ return RSPayload
420
+
421
+
422
+ @lru_cache(maxsize=None)
423
+ def _create_response_common_type(tag_type: Type) -> Type[ResponseCommon]:
424
+ """Create a new wrapper for the ResponseCommon type with the given tag.
425
+
426
+ This method is intended to save us the overhead of writing a new class for each tag type. Instead, we can
427
+ create a new class at runtime that contains the ResponseCommon type, and use that for deserialization.
428
+
429
+ Arguments:
430
+ tag_type (Type): The type of tag to use for the wrapper.
431
+
432
+ Returns: The wrapper type that will be used for deserialization.
433
+ """ # fmt: skip
434
+ # First, create a new wrapper type that contains the ResponseCommon type with the appropriate XML tag
435
+ class Wrapper(ResponseCommon, tag=tag_type.__name__): # type: ignore[call-arg]
436
+ """Wrapper for the validation object with the proper XML tag."""
437
+
438
+ # Finally, return the wrapper type so we can instantiate it
439
+ return Wrapper
440
+
441
+
442
+ @lru_cache(maxsize=None)
443
+ def _create_request_payload_type(key: str, envelope_type: Type[E], data_type: Type[P]) -> Type[PayloadBase]:
444
+ """Create a new payload type for the given payload and data types.
445
+
446
+ This method is intended to save us the overhead of writing a new class for each payload type. Instead, we can
447
+ create a new class at runtime that contains the payload and data types, and use that for serialization.
448
+
449
+ Note that this method has been LRU-cached because the operation of creating a new class from data types at
450
+ runtime involves lots of reflection and is quite slow. By caching the results, we can avoid the overhead of
451
+ creating new classes every time we need to serialize data.
452
+
453
+ Arguments:
454
+ key: str The tag to use for the parent element of the payload.
455
+ envelope_type (Type[Envelope]): The type of payload to be constructed.
456
+ data_type (Type[Payload]): The type of data to be constructed.
457
+
458
+ Returns: A new payload type that can be used for serialization.
459
+ """ # fmt: skip
460
+ # First, create a wrapper for our data type that will be used to store the data in the payload
461
+ class Envelope(envelope_type): # type: ignore[valid-type, misc]
462
+ """Wrapper for the data type that will be used to store the data in the payload."""
463
+
464
+ # The data to be stored in the payload
465
+ data: data_type = element(tag=data_type.__name__) # type: ignore[valid-type]
466
+
467
+ def __init__(self, envelope: envelope_type, data: data_type): # type: ignore[valid-type]
468
+ """Create a new envelope to store payload data.
469
+
470
+ Arguments:
471
+ envelope (Envelope): The payload to be stored in the data.
472
+ data (Payload): The data to be stored in the payload.
473
+ """
474
+ obj = dict(envelope)
475
+ obj["data"] = data
476
+ super().__init__(**obj)
477
+
478
+ # Next, create our payload type that actually contains all the XML data
479
+ class RQPayload(PayloadBase, tag=key): # type: ignore[call-arg]
480
+ """The payload type that will be used for serialization."""
481
+
482
+ # The payload containing our request object and any data
483
+ envelope: Envelope = element(tag=envelope_type.__name__)
484
+
485
+ def __init__(self, envelope: envelope_type, data: data_type, schema: str): # type: ignore[valid-type]
486
+ """Create a new payload containing the request object and any data.
487
+
488
+ Arguments:
489
+ envelope (Envelope): The payload to be stored in the data.
490
+ data (Payload): The data to be stored in the payload.
491
+ schema (str): The name of the schema file to use for validation.
492
+ """
493
+ super().__init__(location=schema, envelope=Envelope(envelope, data))
494
+
495
+ # Finally, return the payload type so we can instantiate it
496
+ return RQPayload
497
+
498
+
499
+ def _find_or_fail(node: Element, tag: str) -> Element:
500
+ """Find the node with the given tag, or raise an error if it isn't found.
501
+
502
+ Arguments:
503
+ node (Element): The node to search for the tag.
504
+ tag (str): The tag to search for in the node.
505
+
506
+ Returns: The node with the given tag.
507
+
508
+ Raises: ValueError if the tag isn't found in the node.
509
+ """
510
+ found = node.find(tag)
511
+ if found is None:
512
+ raise ValueError(f"Expected tag '{tag}' not found in node") # pragma: no cover
513
+ return found