mms-client 1.6.0__py3-none-any.whl → 1.8.0__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,259 @@
1
+ """Contains a transport override supporting MTOMS attachments."""
2
+
3
+ import string
4
+ from base64 import b64encode
5
+ from email.encoders import encode_7or8bit
6
+ from email.mime.application import MIMEApplication
7
+ from email.mime.base import MIMEBase
8
+ from email.mime.multipart import MIMEMultipart
9
+ from logging import getLogger
10
+ from random import SystemRandom
11
+ from typing import Dict
12
+ from typing import Optional
13
+
14
+ from lxml import etree
15
+ from lxml.etree import _Element as Element
16
+ from pendulum import now
17
+ from requests import Session
18
+ from zeep.cache import VersionedCacheBase
19
+ from zeep.transports import Transport
20
+ from zeep.wsdl.utils import etree_to_string
21
+
22
+ # Set the default logger for the MMS client
23
+ logger = getLogger(__name__)
24
+
25
+
26
+ # Define the namespaces used in the XML
27
+ XOP = "http://www.w3.org/2004/08/xop/include"
28
+ XMIME5 = "http://www.w3.org/2005/05/xmlmime"
29
+ FILETAG = "xop:Include:"
30
+ ID_LEN = 16
31
+
32
+
33
+ # Define a function to generate a randomized ID string
34
+ def get_id(length: int = ID_LEN) -> str:
35
+ """Generate a randomized ID string.
36
+
37
+ Arguments:
38
+ length (int): The length of the ID string to generate.
39
+
40
+ Returns: The randomized ID string.
41
+ """
42
+ return "".join(SystemRandom().choice(string.ascii_uppercase + string.digits) for _ in range(length))
43
+
44
+
45
+ def now_b64():
46
+ """Return a base64 encoded string of the current timestamp."""
47
+ return b64encode(f"{now().timestamp()}".replace(".", "").encode("UTF-8")).decode("UTF-8")
48
+
49
+
50
+ def get_boundary() -> str:
51
+ """Return a randomized MIME boundary string."""
52
+ return f"MIMEBoundary_{now_b64()}".center(33, "=")
53
+
54
+
55
+ def get_content_id(domain: str) -> str:
56
+ """Return a randomized MIME content ID string.
57
+
58
+ Arguments:
59
+ domain (str): The domain of the content ID.
60
+
61
+ Returns: The randomized MIME content ID string.
62
+ """
63
+ return f"<{now_b64()}@{domain}>"
64
+
65
+
66
+ def overwrite_attachnode(node):
67
+ """Overwrite the attachment node.
68
+
69
+ Arguments:
70
+ node (Element): The XML node to be overwritten.
71
+
72
+ Returns: The attachment node.
73
+ """
74
+ cid = node.text[len(FILETAG) :]
75
+ node.text = None
76
+ etree.SubElement(node, f"{{{XOP}}}Include", nsmap={"xop": XOP}, href=f"cid:{cid}")
77
+ return cid
78
+
79
+
80
+ def get_multipart(content_id: str) -> MIMEMultipart:
81
+ """Create a MIME multipart object.
82
+
83
+ Arguments:
84
+ content_id (str): The content ID to be used for the attachment.
85
+
86
+ Returns: The MIME multipart object.
87
+ """
88
+ part = MIMEMultipart(
89
+ "related", charset="UTF-8", type="application/xop+xml", boundary=get_boundary(), start=content_id
90
+ )
91
+ part.set_param("start-info", "application/soap+xml")
92
+ return part
93
+
94
+
95
+ def get_envelope_part(envelope: Element, content_id: str) -> MIMEApplication:
96
+ """Create the MIME envelope part.
97
+
98
+ Arguments:
99
+ envelope (Element): The XML envelope to be attached.
100
+ content_id (str): The content ID to be used for the attachment.
101
+
102
+ Returns: The MIME envelope part.
103
+ """
104
+ part = MIMEApplication(etree_to_string(envelope), "xop+xml", encode_7or8bit)
105
+ part.set_param("charset", "utf-8")
106
+ part.set_param("type", "text/xml")
107
+ part.add_header("Content-ID", content_id)
108
+ part.add_header("Content-Transfer-Encoding", "binary")
109
+ return part
110
+
111
+
112
+ class Attachment:
113
+ """Represents an attachment to be sent with a multipart request."""
114
+
115
+ def __init__(self, name: str, data: bytes, domain: str):
116
+ """Create a new attachment.
117
+
118
+ Arguments:
119
+ name (str): The name of the attachment.
120
+ data (bytes): The data to be attached.
121
+ domain (str): The domain of the content ID.
122
+ """
123
+ self.name = name
124
+ self.data = data
125
+ self.cid = f"{get_id()}-{name}@{domain}"
126
+ self.tag = FILETAG + self.cid
127
+
128
+
129
+ class MultipartTransport(Transport):
130
+ """A transport that supports MTOMS attachments."""
131
+
132
+ def __init__(
133
+ self,
134
+ domain: str,
135
+ cache: Optional[VersionedCacheBase] = None,
136
+ timeout: int = 300,
137
+ operation_timeout: Optional[int] = None,
138
+ session: Optional[Session] = None,
139
+ ):
140
+ """Create a new MTOMS transport.
141
+
142
+ Arguments:
143
+ domain (str): The domain of the content ID to use.
144
+ cache (VersionedCacheBase, optional): The cache to be used for the transport.
145
+ timeout (int): The timeout for the transport.
146
+ operation_timeout (int, optional): The operation timeout for the transport.
147
+ session (Session, optional): The session to be used for the transport.
148
+ """
149
+ # Save the domain for later use
150
+ self._domain = domain
151
+
152
+ # Setup a dictionary to store the attachments after they're registered
153
+ self._attachments: Dict[str, Attachment] = {}
154
+
155
+ # Call the parent constructor
156
+ super().__init__(
157
+ cache=cache,
158
+ timeout=timeout,
159
+ operation_timeout=operation_timeout,
160
+ session=session,
161
+ )
162
+
163
+ def register_attachment(self, name: str, data: bytes) -> str:
164
+ """Register an attachment.
165
+
166
+ Registered attachments will be sent with the request as MTOMS attachments. The content ID of the attachment
167
+ will be returned so that it can be used in the request.
168
+
169
+ Arguments:
170
+ name (str): The name of the attachment.
171
+ data (bytes): The data to be attached.
172
+
173
+ Returns: The content ID of the attachment, which should be used in place of the attachment data.
174
+ """
175
+ attachment = Attachment(name, data, self._domain)
176
+ self._attachments[attachment.cid] = attachment
177
+ return attachment.tag
178
+
179
+ def post_xml(self, address: str, envelope: Element, headers: dict):
180
+ """Post the XML envelope and attachments.
181
+
182
+ Arguments:
183
+ address (str): The address to post the data to.
184
+ envelope (Element): The XML envelope to be attached.
185
+ headers (dict): The headers to be used for the request.
186
+
187
+ Returns: The response from the server.
188
+ """
189
+ # Search for values that start with our FILETAG
190
+ filetags = envelope.xpath(f"//*[starts-with(text(), '{FILETAG}')]")
191
+
192
+ # if there are any attached files, we will set the attachments. Otherwise, just the envelope
193
+ if filetags:
194
+ message = self.create_mtom_request(filetags, envelope, headers).encode("UTF-8")
195
+ else:
196
+ message = etree_to_string(envelope)
197
+
198
+ # Post the request and return the response
199
+ return self.post(address, message, headers)
200
+
201
+ def create_mtom_request(self, filetags, envelope: Element, headers: dict) -> str:
202
+ """Set MTOM attachments and return the right envelope.
203
+
204
+ Arguments:
205
+ filetags (list): The list of XML paths to the attachments.
206
+ envelope (Element): The XML envelope to be attached.
207
+ headers (dict): The headers to be used for the request.
208
+
209
+ Returns: The XML envelope with the attachments.
210
+ """
211
+ # First, get an identifier for the request and then use it to create a new multipart request
212
+ content_id = get_content_id(self._domain)
213
+ mtom_part = get_multipart(content_id)
214
+
215
+ # Next, let's set the XOP:Include nodes for each attachment
216
+ files = [overwrite_attachnode(f) for f in filetags]
217
+
218
+ # Now, create the request envelope and attach it to the multipart request
219
+ env_part = get_envelope_part(envelope, content_id)
220
+ mtom_part.attach(env_part)
221
+
222
+ # Attach each file to the multipart request
223
+ for cid in files:
224
+ mtom_part.attach(self.create_attachment(cid))
225
+
226
+ # Finally, create the final multipart request string
227
+ bound = f"--{mtom_part.get_boundary()}"
228
+ marray = mtom_part.as_string().split(bound)
229
+ mtombody = bound + bound.join(marray[1:])
230
+
231
+ # Set the content length and add the MTOM headers to the request
232
+ mtom_part.add_header("Content-Length", str(len(mtombody)))
233
+ headers.update(dict(mtom_part.items()))
234
+
235
+ # Decode the XML and return the request
236
+ message = mtom_part.as_string().split("\n\n", 1)[1]
237
+ message = message.replace("\n", "\r\n", 5)
238
+ return message
239
+
240
+ def create_attachment(self, cid):
241
+ """Create an attachment for the multipart request.
242
+
243
+ Arguments:
244
+ cid (str): The content ID of the attachment.
245
+
246
+ Returns: The attachment.
247
+ """
248
+ # First, get the attachment from the cache
249
+ attach = self._attachments[cid]
250
+
251
+ # Next, create the attachment
252
+ part = MIMEBase("application", "octet-stream")
253
+ part["Content-Transfer-Encoding"] = "binary"
254
+ part["Content-ID"] = f"<{attach.cid}>"
255
+ part.set_payload(attach.data, charset="utf-8")
256
+ del part["mime-version"]
257
+
258
+ # Finally, return the attachment
259
+ return part
@@ -56,89 +56,128 @@ class Serializer:
56
56
  with open(XSD_DIR / self._xsd.value, "rb") as f:
57
57
  self._schema = XMLSchema(parse(f))
58
58
 
59
- def serialize(self, request_envelope: E, request_data: P) -> bytes:
59
+ def serialize(self, request_envelope: E, request_data: P, for_report: bool = False) -> bytes:
60
60
  """Serialize the envelope and data to a byte string for sending to the MMS server.
61
61
 
62
62
  Arguments:
63
63
  request_envelope (Envelope): The envelope to be serialized.
64
64
  request_data (Payload): The data to be serialized.
65
+ for_report (bool): If True, the data will be serialized for a report request.
65
66
 
66
67
  Returns: A byte string containing the XML-formatted data to be sent to the MMS server.
67
68
  """
68
- # First, create our payload class from the payload and data types
69
- payload_cls = _create_request_payload_type(
69
+ # First, choose the correct payload factory based on the request type
70
+ factory = _create_report_payload_type if for_report else _create_request_payload_type
71
+
72
+ # Next, create our payload class from the payload and data types
73
+ payload_cls = factory(
70
74
  self._payload_key,
71
75
  type(request_envelope),
72
76
  type(request_data),
73
77
  False, # type: ignore[arg-type]
74
78
  )
75
79
 
76
- # Next, inject the payload and data into the payload class
80
+ # Now, inject the payload and data into the payload class
77
81
  # NOTE: this returns a type that inherits from PayloadBase and the arguments provided to the initializer
78
82
  # here are correct, but mypy thinks they are incorrect because it doesn't understand the the inherited type
79
83
  payload = payload_cls(request_envelope, request_data, self._xsd.value) # type: ignore[call-arg, misc]
80
84
 
81
85
  # Finally, convert the payload to XML and return it
82
86
  # NOTE: we provided the encoding here so this will return bytes, not a string
83
- return payload.to_xml(skip_empty=True, encoding="utf-8", xml_declaration=True) # type: ignore[return-value]
87
+ return self._to_canoncialized_xml(payload)
84
88
 
85
- def serialize_multi(self, request_envelope: E, request_data: List[P], request_type: Type[P]) -> bytes:
89
+ def serialize_multi(
90
+ self, request_envelope: E, request_data: List[P], request_type: Type[P], for_report: bool = False
91
+ ) -> bytes:
86
92
  """Serialize the envelope and data to a byte string for sending to the MMS server.
87
93
 
88
94
  Arguments:
89
95
  request_envelope (Envelope): The envelope to be serialized.
90
- request_data (List[Payload]): The data to be serialized.
91
- request_type (Type[Payload]): The type of data to be serialized.
96
+ request_data (List[Payload]): The data to be serialized.
97
+ request_type (Type[Payload]): The type of data to be serialized.
98
+ for_report (bool): If True, the data will be serialized for a report request.
92
99
 
93
100
  Returns: A byte string containing the XML-formatted data to be sent to the MMS server.
94
101
  """
95
- # First, create our payload class from the payload and data types
96
- payload_cls = _create_request_payload_type(
97
- self._payload_key, type(request_envelope), request_type, True # type: ignore[arg-type]
98
- )
102
+ # First, choose the correct payload factory based on the request type
103
+ factory = _create_report_payload_type if for_report else _create_request_payload_type
104
+
105
+ # Next, create our payload class from the payload and data types
106
+ payload_cls = factory(self._payload_key, type(request_envelope), request_type, True) # type: ignore[arg-type]
99
107
 
100
- # Next, inject the payload and data into the payload class
108
+ # Now, inject the payload and data into the payload class
101
109
  # NOTE: this returns a type that inherits from PayloadBase and the arguments provided to the initializer
102
110
  # here are correct, but mypy thinks they are incorrect because it doesn't understand the the inherited type
103
111
  payload = payload_cls(request_envelope, request_data, self._xsd.value) # type: ignore[call-arg, misc]
104
112
 
105
113
  # Finally, convert the payload to XML and return it
106
114
  # NOTE: we provided the encoding here so this will return bytes, not a string
107
- return payload.to_xml(skip_empty=True, encoding="utf-8", xml_declaration=True) # type: ignore[return-value]
115
+ return self._to_canoncialized_xml(payload)
108
116
 
109
- def deserialize(self, data: bytes, envelope_type: Type[E], data_type: Type[P]) -> Response[E, P]:
117
+ def deserialize(
118
+ self, data: bytes, envelope_type: Type[E], data_type: Type[P], for_report: bool = False
119
+ ) -> Response[E, P]:
110
120
  """Deserialize the data to a response object.
111
121
 
112
122
  Arguments:
113
123
  data (bytes): The raw data to be deserialized.
114
124
  envelope_type (Type[Envelope]): The type of envelope to be constructed.
115
125
  data_type (Type[Payload]): The type of data to be constructed.
126
+ for_report (bool): If True, the data will be serialized for a report request.
116
127
 
117
128
  Returns: A response object containing the envelope and data extracted from the raw data.
118
129
  """
119
130
  tree = self._from_xml(data)
120
- return self._from_tree(tree, envelope_type, data_type)
131
+ return self._from_tree(tree, envelope_type, data_type, for_report)
121
132
 
122
- def deserialize_multi(self, data: bytes, envelope_type: Type[E], data_type: Type[P]) -> MultiResponse[E, P]:
133
+ def deserialize_multi(
134
+ self, data: bytes, envelope_type: Type[E], data_type: Type[P], for_report: bool = False
135
+ ) -> MultiResponse[E, P]:
123
136
  """Deserialize the data to a multi-response object.
124
137
 
125
138
  Arguments:
126
139
  data (bytes): The raw data to be deserialized.
127
140
  envelope_type (Type[Envelope]): The type of envelope to be constructed.
128
141
  data_type (Type[Payload]): The type of data to be constructed.
142
+ for_report (bool): If True, the data will be serialized for a report request.
129
143
 
130
144
  Returns: A multi-response object containing the envelope and data extracted from the raw data.
131
145
  """
132
146
  tree = self._from_xml(data)
133
- return self._from_tree_multi(tree, envelope_type, data_type)
147
+ return self._from_tree_multi(tree, envelope_type, data_type, for_report)
148
+
149
+ def _to_canoncialized_xml(self, payload: PayloadBase) -> bytes:
150
+ """Convert the payload to a canonicalized XML string.
151
+
152
+ Arguments:
153
+ payload (PayloadBase): The payload to be converted.
134
154
 
135
- def _from_tree(self, raw: Element, envelope_type: Type[E], data_type: Type[P]) -> Response[E, P]:
155
+ Returns: The canonicalized XML string.
156
+ """
157
+ # First, convert the payload to a raw XML string
158
+ raw: bytes = payload.to_xml(
159
+ skip_empty=True,
160
+ encoding="utf-8",
161
+ xml_declaration=False,
162
+ ) # type: ignore[assignment]
163
+
164
+ # Next, parse it back into an XML tree
165
+ unparsed = parse(BytesIO(raw))
166
+
167
+ # Finally, convert the XML tree to a canonicalized XML string and return it
168
+ buffer = BytesIO()
169
+ unparsed.write_c14n(buffer)
170
+ buffer.seek(0)
171
+ return buffer.read()
172
+
173
+ def _from_tree(self, raw: Element, envelope_type: Type[E], data_type: Type[P], for_report: bool) -> Response[E, P]:
136
174
  """Convert the raw data to a response object.
137
175
 
138
176
  Arguments:
139
177
  raw (Element): The raw data to be converted.
140
178
  envelope_type (Type[Envelope]): The type of envelope to be constructed.
141
179
  data_type (Type[Payload]): The type of data to be constructed.
180
+ for_report (bool): If True, the data will be serialized for a report request.
142
181
 
143
182
  Returns: A response object containing the envelope and data extracted from the raw data.
144
183
  """
@@ -155,7 +194,9 @@ class Serializer:
155
194
  resp = cls.from_xml_tree(raw) # type: ignore[arg-type]
156
195
 
157
196
  # Next, attempt to extract the envelope and data from within the response
158
- resp.envelope, resp.envelope_validation, envelope_node = self._from_tree_envelope(raw, envelope_type)
197
+ resp.envelope, resp.envelope_validation, envelope_node = self._from_tree_envelope(
198
+ raw, envelope_type, for_report
199
+ )
159
200
 
160
201
  # Now, verify that the response doesn't contain an unexpected data type and then retrieve the payload data
161
202
  # from within the envelope
@@ -163,18 +204,23 @@ class Serializer:
163
204
  resp.payload = self._from_tree_data(envelope_node.find(get_tag(data_type)), data_type)
164
205
 
165
206
  # Finally, attempt to extract the messages from within the payload
166
- resp.messages = self._from_tree_messages(raw, envelope_type, data_type, self._payload_key, False)
207
+ resp.messages = self._from_tree_messages(
208
+ raw, get_tag(envelope_type), data_type, self._payload_key, False, for_report=for_report
209
+ )
167
210
 
168
211
  # Return the response
169
212
  return resp
170
213
 
171
- def _from_tree_multi(self, raw: Element, envelope_type: Type[E], data_type: Type[P]) -> MultiResponse[E, P]:
214
+ def _from_tree_multi(
215
+ self, raw: Element, envelope_type: Type[E], data_type: Type[P], for_report: bool
216
+ ) -> MultiResponse[E, P]:
172
217
  """Convert the raw data to a multi-response object.
173
218
 
174
219
  Arguments:
175
220
  raw (Element): The raw data to be converted.
176
221
  envelope_type (Type[Envelope]): The type of envelope to be constructed.
177
222
  data_type (Type[Payload]): The type of data to be constructed.
223
+ for_report (bool): If True, the data will be serialized for a report request.
178
224
 
179
225
  Returns: A multi-response object containing the envelope and data extracted from the raw data.
180
226
  """
@@ -191,7 +237,7 @@ class Serializer:
191
237
  resp = cls.from_xml_tree(raw) # type: ignore[arg-type]
192
238
 
193
239
  # Next, attempt to extract the envelope from the response
194
- resp.envelope, resp.envelope_validation, env_node = self._from_tree_envelope(raw, envelope_type)
240
+ resp.envelope, resp.envelope_validation, env_node = self._from_tree_envelope(raw, envelope_type, for_report)
195
241
 
196
242
  # Now, verify that the response doesn't contain an unexpected data type and then retrieve the payload data
197
243
  # from within the envelope
@@ -202,17 +248,22 @@ class Serializer:
202
248
  ]
203
249
 
204
250
  # Finally, attempt to extract the messages from within the payload
205
- resp.messages = self._from_tree_messages(raw, envelope_type, data_type, self._payload_key, True)
251
+ resp.messages = self._from_tree_messages(
252
+ raw, get_tag(envelope_type), data_type, self._payload_key, True, for_report=for_report
253
+ )
206
254
 
207
255
  # Return the response
208
256
  return resp
209
257
 
210
- def _from_tree_envelope(self, raw: Element, envelope_type: Type[E]) -> Tuple[E, ResponseCommon, Element]:
258
+ def _from_tree_envelope(
259
+ self, raw: Element, envelope_type: Type[E], for_report: bool
260
+ ) -> Tuple[E, ResponseCommon, Element]:
211
261
  """Attempt to extract the envelope from within the response.
212
262
 
213
263
  Arguments:
214
264
  raw (Element): The raw data to be converted.
215
265
  envelope_type (Type[Envelope]): The type of envelope to be constructed.
266
+ for_report (bool): If True, the data will be serialized for a report request.
216
267
 
217
268
  Returns:
218
269
  Envelope: The request payload constructed from the raw data.
@@ -220,9 +271,10 @@ class Serializer:
220
271
  """
221
272
  # First, attempt to extract the envelope from within the response; if the key isn't found then we'll raise an
222
273
  # exception to indicate that the envelope wasn't found.
223
- envelope_node = raw.find(envelope_type.__name__)
224
- if envelope_node is None:
225
- raise ValueError(f"Expected envelope type '{envelope_type.__name__}' not found in response")
274
+ envelope_tag = get_tag(envelope_type)
275
+ envelope_node = raw if for_report else raw.find(envelope_tag)
276
+ if envelope_node is None or envelope_node.tag != envelope_tag:
277
+ raise ValueError(f"Expected envelope type '{envelope_tag}' not found in response")
226
278
 
227
279
  # Next, create a new envelope type that contains the envelope type with the appropriate XML tag. We have to do
228
280
  # this because the envelope type doesn't include the ResponseCommon fields, and the tag doesn't match
@@ -247,7 +299,7 @@ class Serializer:
247
299
  ValueError: If the expected data type is not found in the response.
248
300
  """
249
301
  data_tags = set(node.tag for node in raw)
250
- if not data_tags.issubset([data_type.__name__, data_type.__xml_tag__, "Messages"]):
302
+ if not data_tags.issubset([data_type.__name__, data_type.__xml_tag__, "ProcessingStatistics", "Messages"]):
251
303
  raise ValueError(f"Expected data type '{data_type.__name__}' not found in response")
252
304
 
253
305
  def _from_tree_data(self, raw: Optional[Element], data_type: Type[P]) -> Optional[ResponseData[P]]:
@@ -278,11 +330,12 @@ class Serializer:
278
330
  def _from_tree_messages(
279
331
  self,
280
332
  raw: Element,
281
- envelope_type: Type[E],
333
+ envelope_tag: str,
282
334
  current_type: Type[P],
283
335
  root: str,
284
336
  multi: bool,
285
337
  wrapped: bool = False,
338
+ for_report: bool = False,
286
339
  ) -> Dict[str, Messages]:
287
340
  """Attempt to extract the messages from within the payload.
288
341
 
@@ -292,13 +345,14 @@ class Serializer:
292
345
 
293
346
  Arguments:
294
347
  raw (Element): The raw data to be converted.
295
- envelope_type (Type[Envelope]): The type of envelope being constructed.
348
+ envelope_tag (str): The tag of the envelope being constructed.
296
349
  current_type (Type[Payload]): The type of data being constructed.
297
350
  root (str): The root of the dictionary, used to create the key for the messages.
298
351
  multi (bool): Whether we're processing a list of nodes or a single node. If called with the
299
352
  payload root, this value will determine whether we're processing a multi-
300
353
  response or a single response.
301
354
  wrapped (bool): Whether or not this type is referenced from a wrapped field.
355
+ for_report (bool): If True, the data will be serialized for a report request.
302
356
  """
303
357
  # First, find the Messages node in the raw data
304
358
  message_node = raw.find("Messages")
@@ -313,21 +367,21 @@ class Serializer:
313
367
  # at the root of the response, we need to call this method for the envelope type. If we are at the envelope
314
368
  # type, we need to call this method for the data type. If we are at the data type, we need to call this method
315
369
  # for each field in the data type that is a Payload type.
316
- if root == self._payload_key:
370
+ if root == self._payload_key and not for_report:
317
371
  messages.update(
318
372
  self._from_tree_messages(
319
- _find_or_fail(raw, envelope_type.__name__),
320
- envelope_type,
373
+ _find_or_fail(raw, envelope_tag),
374
+ envelope_tag,
321
375
  current_type,
322
- f"{root}.{envelope_type.__name__}",
376
+ f"{root}.{envelope_tag}",
323
377
  multi,
324
378
  False,
325
379
  )
326
380
  )
327
- elif root.endswith(envelope_type.__name__) or wrapped:
381
+ elif root.endswith(envelope_tag) or wrapped:
328
382
  messages.update(
329
383
  self._from_tree_messages_inner(
330
- raw, envelope_type, current_type, root, get_tag(current_type), multi, False
384
+ raw, envelope_tag, current_type, root, get_tag(current_type), multi, False
331
385
  )
332
386
  )
333
387
  else:
@@ -350,7 +404,7 @@ class Serializer:
350
404
  messages.update(
351
405
  self._from_tree_messages_inner(
352
406
  raw,
353
- envelope_type,
407
+ envelope_tag,
354
408
  arg,
355
409
  root,
356
410
  field.path, # type: ignore[attr-defined]
@@ -365,7 +419,7 @@ class Serializer:
365
419
  def _from_tree_messages_inner(
366
420
  self,
367
421
  raw: Element,
368
- envelope_type: Type[E],
422
+ envelope_tag: str,
369
423
  current_type: Type[P],
370
424
  root: str,
371
425
  tag: str,
@@ -376,7 +430,7 @@ class Serializer:
376
430
 
377
431
  Arguments:
378
432
  raw (Element): The raw data to be converted.
379
- envelope_type (Type[Envelope]): The type of envelope being constructed.
433
+ envelope_tag (str): The tag of the envelope being constructed.
380
434
  current_type (Type[Payload]): The type of data being constructed.
381
435
  root (str): The root of the dictionary, used to create the key for the messages.
382
436
  tag (str): The tag of the current node being processed.
@@ -402,7 +456,7 @@ class Serializer:
402
456
  for i, node in enumerate(nodes):
403
457
  messages.update(
404
458
  self._from_tree_messages(
405
- node, envelope_type, current_type, path_base if wrapped else f"{path_base}[{i}]", True, wrapped
459
+ node, envelope_tag, current_type, path_base if wrapped else f"{path_base}[{i}]", True, wrapped
406
460
  )
407
461
  )
408
462
  return messages
@@ -412,7 +466,7 @@ class Serializer:
412
466
  return (
413
467
  {}
414
468
  if child is None
415
- else self._from_tree_messages(child, envelope_type, current_type, path_base, False, wrapped)
469
+ else self._from_tree_messages(child, envelope_tag, current_type, path_base, False, wrapped)
416
470
  )
417
471
 
418
472
  def _from_xml(self, data: bytes) -> Element:
@@ -497,7 +551,7 @@ def _create_response_common_type(tag_type: Type[Union[E, P]]) -> Type[ResponseCo
497
551
  def _create_request_payload_type(
498
552
  key: str,
499
553
  envelope_type: Type[E],
500
- data_type: Type[Union[P, List[P]]],
554
+ data_type: Type[P],
501
555
  multi: bool,
502
556
  ) -> Type[PayloadBase]:
503
557
  """Create a new payload type for the given payload and data types.
@@ -559,6 +613,45 @@ def _create_request_payload_type(
559
613
  return RQPayload
560
614
 
561
615
 
616
+ @lru_cache(maxsize=None)
617
+ def _create_report_payload_type(key: str, envelope_type: Type[E], data_type: Type[P], multi: bool) -> Type[PayloadBase]:
618
+ """Create a new payload type for the given report data request.
619
+
620
+ Arguments:
621
+ key: str The tag to use for the parent element of the payload.
622
+ envelope_type (Type[Envelope]): The type of payload to be used for the envelope.
623
+ data_type (Type[Payload]): The type of payload to be used for the data.
624
+ multi (bool): If True, the payload will be a list; otherwise, it will be a singleton.
625
+
626
+ Returns: The base payload type that will be used for serialization.
627
+ """
628
+ # First, create our data type
629
+ payload_type = List[data_type] if multi else data_type # type: ignore[valid-type]
630
+
631
+ # Next, create a wrapper for our data type that will be used to store the data in the payload
632
+ class Envelope(PayloadBase, envelope_type, tag=key): # type: ignore[valid-type, misc]
633
+ """Wrapper for the data type that will be used to store the data in the payload."""
634
+
635
+ # The data to be stored in the payload
636
+ data: payload_type = element(tag=get_tag(data_type)) # type: ignore[valid-type]
637
+
638
+ def __init__(self, envelope: envelope_type, data: payload_type, schema: str): # type: ignore[valid-type]
639
+ """Create a new envelope to store payload data.
640
+
641
+ Arguments:
642
+ envelope (Envelope): The payload to be stored in the data.
643
+ data (Payload): The data to be stored in the payload.
644
+ schema (str): The name of the schema file to use for validation.
645
+ """
646
+ obj = dict(envelope)
647
+ obj["data"] = data
648
+ obj["location"] = schema
649
+ super().__init__(**obj)
650
+
651
+ # Finally, return the payload type so we can instantiate it
652
+ return Envelope
653
+
654
+
562
655
  def _find_or_fail(node: Element, tag: str) -> Element:
563
656
  """Find the node with the given tag, or raise an error if it isn't found.
564
657
 
@@ -615,7 +708,7 @@ def _get_field_typing(typ: Type) -> Tuple[Type, bool]:
615
708
  return args[-1][0] if len(args) > 0 else typ, get_origin(origin_type) is list # typing: ignore[return-value]
616
709
 
617
710
 
618
- def get_tag(data_type: Type[P]) -> str:
711
+ def get_tag(data_type: Union[Type[P], Type[E]]) -> str:
619
712
  """Get the tag for the given data type.
620
713
 
621
714
  Arguments: