mms-client 1.5.1__py3-none-any.whl → 1.7.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.
@@ -1,14 +1,24 @@
1
1
  """Contains functionality associated with certificates."""
2
2
 
3
3
  from pathlib import Path
4
+ from ssl import PROTOCOL_TLSv1_2
4
5
  from typing import Union
5
6
 
7
+ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
8
+ from cryptography.hazmat.primitives.serialization import Encoding
9
+ from cryptography.hazmat.primitives.serialization import NoEncryption
10
+ from cryptography.hazmat.primitives.serialization import PrivateFormat
11
+ from cryptography.hazmat.primitives.serialization import PublicFormat
12
+ from cryptography.hazmat.primitives.serialization.pkcs12 import load_key_and_certificates
6
13
  from requests_pkcs12 import Pkcs12Adapter
7
14
 
8
15
 
9
16
  class Certificate:
10
17
  """Describes a certificate composed of a cert file and a key file."""
11
18
 
19
+ # The encoding to use for MMS certificates
20
+ encoding = Encoding.PEM
21
+
12
22
  def __init__(self, cert: Union[str, Path, bytes], passphrase: str):
13
23
  """Create a new Certificate.
14
24
 
@@ -29,6 +39,13 @@ class Certificate:
29
39
  # Save the passphrase
30
40
  self._passphrase = passphrase
31
41
 
42
+ # Load the private key using the cryptography library
43
+ private_key, _, _ = load_key_and_certificates(self._cert, self._passphrase.encode("UTF-8"))
44
+ if isinstance(private_key, RSAPrivateKey):
45
+ self._private = private_key
46
+ else:
47
+ raise TypeError(f"Private key of type ({type(private_key).__name__}) was not expected.")
48
+
32
49
  @property
33
50
  def certificate(self) -> bytes:
34
51
  """Return the certificate data."""
@@ -39,6 +56,22 @@ class Certificate:
39
56
  """Return the full path to the passphrase."""
40
57
  return self._passphrase
41
58
 
59
+ def public_key(self) -> bytes:
60
+ """Extract the public key from the certificate.
61
+
62
+ Returns: The public key in PEM format.
63
+ """
64
+ return self._private.public_key().public_bytes(Certificate.encoding, PublicFormat.PKCS1)
65
+
66
+ def private_key(self) -> bytes:
67
+ """Extract the private key from the certificate.
68
+
69
+ THIS SHOULD NOT, UNDER ANY CIRCUMSTANCES, BE PRINTED OR LOGGED!!!
70
+
71
+ Returns: The private key in PEM format.
72
+ """
73
+ return self._private.private_bytes(Certificate.encoding, PrivateFormat.PKCS8, NoEncryption())
74
+
42
75
  def to_adapter(self) -> Pkcs12Adapter:
43
76
  """Convert the certificate to a Pkcs12Adapter."""
44
- return Pkcs12Adapter(pkcs12_data=self._cert, pkcs12_password=self._passphrase)
77
+ return Pkcs12Adapter(pkcs12_data=self._cert, pkcs12_password=self._passphrase, ssl_protocol=PROTOCOL_TLSv1_2)
@@ -1,13 +1,11 @@
1
1
  """Contains objects for cryptographic operations."""
2
2
 
3
+ from base64 import b64decode
3
4
  from base64 import b64encode
4
- from hashlib import sha256
5
5
 
6
- from cryptography.hazmat.backends import default_backend
7
- from cryptography.hazmat.primitives import hashes
8
- from cryptography.hazmat.primitives.asymmetric import padding
9
- from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
10
- from cryptography.hazmat.primitives.serialization.pkcs12 import load_key_and_certificates
6
+ from Cryptodome.Hash import SHA256
7
+ from Cryptodome.PublicKey import RSA
8
+ from Cryptodome.Signature import pkcs1_15
11
9
 
12
10
  from mms_client.security.certs import Certificate
13
11
 
@@ -21,23 +19,33 @@ class CryptoWrapper:
21
19
  Arguments:
22
20
  cert (Certificate): The certificate to use for cryptographic operations.
23
21
  """
24
- # First, save our certificate for later use
25
- self._cert = cert
22
+ # Extract the public key and private key data from the certificate
23
+ private_key = RSA.import_key(cert.private_key())
24
+ public_key = RSA.import_key(cert.public_key())
26
25
 
27
- # Next, import the private key from the certificate
28
- private_key, _, _ = load_key_and_certificates(
29
- self._cert.certificate, self._cert.passphrase.encode(), default_backend()
30
- )
26
+ # Create a new signer from the private key and a new verifier from the public key
27
+ self._signer = pkcs1_15.new(private_key)
28
+ self._verifier = pkcs1_15.new(public_key)
31
29
 
32
- # Now, we need to assert typing on this private key to make mypy happy
33
- if isinstance(private_key, RSAPrivateKey):
34
- self._private_key = private_key
35
- else:
36
- raise TypeError(f"Private key of type ({type(private_key).__name__}) was not expected.")
30
+ def verify(self, content: bytes, signature: bytes) -> bool:
31
+ """Verify a signature against the given content using the certificate.
37
32
 
38
- # Finally, save our padding and algorithm for later use
39
- self._padding = padding.PKCS1v15()
40
- self._algorithm = hashes.SHA256()
33
+ Arguments:
34
+ content (bytes): The content to verify.
35
+ signature (bytes): The signature to verify against the content.
36
+
37
+ Returns: True if the signature is valid, False otherwise.
38
+ """
39
+ # Hash the content using SHA256
40
+ hashed = SHA256.new(content)
41
+
42
+ # Verify the signature using the public key. This will raise a ValueError if the signature is invalid.
43
+ # We catch this exception and return False to indicate that the signature is invalid.
44
+ try:
45
+ self._verifier.verify(hashed, b64decode(signature))
46
+ return True
47
+ except ValueError:
48
+ return False
41
49
 
42
50
  def sign(self, data: bytes) -> bytes:
43
51
  """Create a signature from the given data using the certificate.
@@ -48,10 +56,10 @@ class CryptoWrapper:
48
56
  Returns: A base64-encoded string containing the signature.
49
57
  """
50
58
  # First, hash the data using SHA256
51
- hashed = sha256(data)
59
+ hashed = SHA256.new(data)
52
60
 
53
61
  # Next, sign the hash using the private key
54
- signature = self._private_key.sign(hashed.digest(), self._padding, self._algorithm)
62
+ signature = self._signer.sign(hashed)
55
63
 
56
64
  # Finally, return the base64-encoded signature
57
65
  return b64encode(signature)
@@ -1,5 +1,6 @@
1
1
  """Contains the client layer for communicating with the MMS server."""
2
2
 
3
+ from base64 import b64decode
3
4
  from dataclasses import dataclass
4
5
  from logging import getLogger
5
6
  from typing import Dict
@@ -28,6 +29,7 @@ from mms_client.types.transport import RequestType
28
29
  from mms_client.types.transport import ResponseDataType
29
30
  from mms_client.utils.errors import AudienceError
30
31
  from mms_client.utils.errors import MMSClientError
32
+ from mms_client.utils.errors import MMSServerError
31
33
  from mms_client.utils.errors import MMSValidationError
32
34
  from mms_client.utils.serialization import Serializer
33
35
  from mms_client.utils.web import ClientType
@@ -247,6 +249,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
247
249
 
248
250
  def __init__(
249
251
  self,
252
+ domain: str,
250
253
  participant: str,
251
254
  user: str,
252
255
  client_type: ClientType,
@@ -258,6 +261,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
258
261
  """Create a new MMS client with the given participant, user, client type, and authentication.
259
262
 
260
263
  Arguments:
264
+ domain (str): The domain to use when signing the content ID to MTOM attachments.
261
265
  participant (str): The MMS code of the business entity to which the requesting user belongs.
262
266
  user (str): The user name of the person making the request.
263
267
  client_type (ClientType): The type of client to use for making requests to the MMS server.
@@ -268,6 +272,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
268
272
  test (bool): Whether to use the test server.
269
273
  """
270
274
  # First, save the base field associated with the client
275
+ self._domain = domain
271
276
  self._participant = participant
272
277
  self._user = user
273
278
  self._client_type = client_type
@@ -328,18 +333,23 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
328
333
 
329
334
  Returns: The response from the MMS server.
330
335
  """
336
+ # Create a new ZWrapper for the given service
337
+ wrapper = self._get_wrapper(config.service)
338
+
331
339
  # First, create the MMS request from the payload and data.
332
340
  logger.debug(
333
341
  f"{config.name}: Starting request. Envelope: {type(envelope).__name__}, Data: {type(payload).__name__}",
334
342
  )
335
- request = self._to_mms_request(config.request_type, config.service.serializer.serialize(envelope, payload))
343
+ request = self._to_mms_request(
344
+ wrapper, config.request_type, config.service.serializer.serialize(envelope, payload)
345
+ )
336
346
 
337
347
  # Next, submit the request to the MMS server and get and verify the response.
338
- resp = self._get_wrapper(config.service).submit(request)
348
+ resp = wrapper.submit(request)
339
349
  self._verify_mms_response(resp, config)
340
350
 
341
351
  # Now, extract the attachments from the response
342
- attachments = {a.name: a.data for a in resp.attachments}
352
+ attachments = {a.name: b64decode(a.data) for a in resp.attachments}
343
353
 
344
354
  # Finally, deserialize and verify the response
345
355
  envelope_type = config.response_envelope_type or type(envelope)
@@ -368,6 +378,9 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
368
378
 
369
379
  Returns: The multi-response from the MMS server.
370
380
  """
381
+ # Create a new ZWrapper for the given service
382
+ wrapper = self._get_wrapper(config.service)
383
+
371
384
  # First, create the MMS request from the payload and data.
372
385
  is_list = isinstance(payload, list)
373
386
  data_type = type(payload[0]) if is_list else type(payload) # type: ignore[index]
@@ -382,14 +395,14 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
382
395
  if is_list
383
396
  else config.service.serializer.serialize(envelope, payload) # type: ignore[type-var]
384
397
  )
385
- request = self._to_mms_request(config.request_type, serialized)
398
+ request = self._to_mms_request(wrapper, config.request_type, serialized)
386
399
 
387
400
  # Next, submit the request to the MMS server and get and verify the response.
388
- resp = self._get_wrapper(config.service).submit(request)
401
+ resp = wrapper.submit(request)
389
402
  self._verify_mms_response(resp, config)
390
403
 
391
404
  # Now, extract the attachments from the response
392
- attachments = {a.name: a.data for a in resp.attachments}
405
+ attachments = {a.name: b64decode(a.data) for a in resp.attachments}
393
406
 
394
407
  # Finally, deserialize and verify the response
395
408
  envelope_type = config.response_envelope_type or type(envelope)
@@ -409,6 +422,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
409
422
 
410
423
  def _to_mms_request(
411
424
  self,
425
+ client: ZWrapper,
412
426
  req_type: RequestType,
413
427
  data: bytes,
414
428
  return_req: bool = False,
@@ -417,6 +431,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
417
431
  """Convert the given data to an MMS request.
418
432
 
419
433
  Arguments:
434
+ client (ZWrapper): The Zeep client to use for submitting the request.
420
435
  req_type (RequestType): The type of request to submit to the MMS server.
421
436
  data (bytes): The data to submit to the MMS server.
422
437
  return_req (bool): Whether to return the request data in the response. This is False by default.
@@ -424,16 +439,14 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
424
439
 
425
440
  Arguments: The MMS request to submit to the MMS server.
426
441
  """
427
- # Convert the attachments to the correct the MMS format
442
+ # First, convert the attachments to the correct the MMS format
428
443
  attachment_data = (
429
- [
430
- Attachment(signature=self._signer.sign(data), name=name, binaryData=data)
431
- for name, data in attachments.items()
432
- ]
433
- if attachments
434
- else []
444
+ [self._to_mms_attachment(client, name, data) for name, data in attachments.items()] if attachments else []
435
445
  )
436
446
 
447
+ # Next, convert the payload to a base-64 string
448
+ tag, signature = self._register_and_sign(client, "payload", data)
449
+
437
450
  # Embed the data and the attachments in the MMS request and return it
438
451
  logger.debug(
439
452
  f"Creating MMS request of type {req_type.name} to send {len(data)} bytes of data and "
@@ -444,11 +457,36 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
444
457
  adminRole=self._is_admin,
445
458
  requestDataType=RequestDataType.XML,
446
459
  sendRequestDataOnSuccess=return_req,
447
- requestSignature=self._signer.sign(data),
448
- requestData=data,
460
+ requestSignature=signature,
461
+ requestData=tag,
449
462
  attachmentData=attachment_data,
450
463
  )
451
464
 
465
+ def _to_mms_attachment(self, client: ZWrapper, name: str, data: bytes) -> Attachment: # pragma: no cover
466
+ """Convert the given data to an MMS attachment.
467
+
468
+ Arguments:
469
+ client (ZWrapper): The Zeep client to use for submitting the request.
470
+ name (str): The name of the attachment.
471
+ data (bytes): The data to be attached.
472
+
473
+ Returns: The MMS attachment.
474
+ """
475
+ # Convert the data to a base-64 string
476
+ tag, signature = self._register_and_sign(client, name, data)
477
+
478
+ # Create the MMS attachment and return it
479
+ return Attachment(signature=signature, name=name, binaryData=tag)
480
+
481
+ def _register_and_sign(self, client: ZWrapper, name: str, data: bytes) -> Tuple[str, str]:
482
+ tag = client.register_attachment(name, data)
483
+
484
+ # Next, sign the data
485
+ signature = self._signer.sign(data)
486
+
487
+ # Finally, convert the encoded data to a string and return it and the signature
488
+ return tag, signature.decode("UTF-8")
489
+
452
490
  def _verify_mms_response(self, resp: MmsResponse, config: EndpointConfiguration) -> None:
453
491
  """Verify that the given MMS response is valid.
454
492
 
@@ -459,7 +497,11 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
459
497
  MMSClientError: If the response is not valid.
460
498
  """
461
499
  # Verify that the response is in the correct format. If it's not, raise an error.
462
- if resp.data_type != ResponseDataType.XML:
500
+ # NOTE: We're disabling the no-else-raise rule here because both comparisons are on the same enum so if one is
501
+ # removed then the other will raise an error. This is a false positive.
502
+ if resp.data_type == ResponseDataType.TXT: # pylint: disable=no-else-raise
503
+ raise MMSServerError(config.name, resp.payload.decode("UTF-8"))
504
+ elif resp.data_type != ResponseDataType.XML:
463
505
  raise MMSClientError(
464
506
  config.name,
465
507
  f"Invalid MMS response data type: {resp.data_type.name}. Only XML is supported.",
@@ -592,6 +634,7 @@ class BaseClient: # pylint: disable=too-many-instance-attributes
592
634
  if service.interface not in self._wrappers:
593
635
  logger.debug(f"Creating wrapper for {service.interface.name} interface.")
594
636
  self._wrappers[service.interface] = ZWrapper(
637
+ self._domain,
595
638
  self._client_type,
596
639
  service.interface,
597
640
  self._cert.to_adapter(),
@@ -18,6 +18,8 @@ from mms_client.types.market import MarketType
18
18
  from mms_client.types.offer import OfferCancel
19
19
  from mms_client.types.offer import OfferData
20
20
  from mms_client.types.offer import OfferQuery
21
+ from mms_client.types.reserve import ReserveRequirement
22
+ from mms_client.types.reserve import ReserveRequirementQuery
21
23
  from mms_client.types.transport import RequestType
22
24
  from mms_client.utils.serialization import SchemaType
23
25
  from mms_client.utils.serialization import Serializer
@@ -34,6 +36,36 @@ class MarketClientMixin: # pylint: disable=unused-argument
34
36
  # The configuration for the market service
35
37
  config = ServiceConfiguration(Interface.MI, Serializer(SchemaType.MARKET, "MarketData"))
36
38
 
39
+ @mms_endpoint(
40
+ "MarketQuery_ReserveRequirementQuery",
41
+ config,
42
+ RequestType.INFO,
43
+ resp_envelope_type=MarketSubmit,
44
+ resp_data_type=ReserveRequirement,
45
+ )
46
+ def query_reserve_requirements(
47
+ self: ClientProto, request: ReserveRequirementQuery, days: int, date: Optional[Date] = None
48
+ ) -> List[ReserveRequirement]:
49
+ """Query the MMS server for reserve requirements.
50
+
51
+ This endpoint is accessible to all client types.
52
+
53
+ Arguments:
54
+ request (ReserveRequirementQuery): The query to submit to the MMS server.
55
+ date (Date): The date of the transaction in the format "YYYY-MM-DD". This value defaults
56
+ to the current date.
57
+ days (int): The number of days ahead for which the data is being queried.
58
+
59
+ Returns: A list of reserve requirements that match the query.
60
+ """
61
+ # NOTE: The return type does not match the method definition but the decorator will return the correct type
62
+ return MarketQuery( # type: ignore[return-value]
63
+ date=date or Date.today(),
64
+ participant=self.participant,
65
+ user=self.user,
66
+ days=days,
67
+ )
68
+
37
69
  @mms_endpoint("MarketSubmit_OfferData", config, RequestType.INFO, [ClientType.BSP])
38
70
  def put_offer(
39
71
  self: ClientProto, request: OfferData, market_type: MarketType, days: int, date: Optional[Date] = None
@@ -124,6 +156,8 @@ class MarketClientMixin: # pylint: disable=unused-argument
124
156
  days (int): The number of days ahead for which the data is being cancelled.
125
157
  date (Date): The date of the transaction in the format "YYYY-MM-DD". This value defaults to the
126
158
  current date.
159
+
160
+ Returns: Data identifying the offer that was cancelled.
127
161
  """
128
162
  # NOTE: The return type does not match the method definition but the decorator will return the correct type
129
163
  return MarketCancel( # type: ignore[return-value]
@@ -134,7 +168,7 @@ class MarketClientMixin: # pylint: disable=unused-argument
134
168
  days=days,
135
169
  )
136
170
 
137
- @mms_endpoint("MarketQuery_AwardResultsQuery", config, RequestType.INFO, resp_data_type=AwardResponse)
171
+ @mms_endpoint("MarketQuery_AwardResultsQuery", config, RequestType.MARKET, resp_data_type=AwardResponse)
138
172
  def query_awards(self: ClientProto, request: AwardQuery, days: int, date: Optional[Date] = None) -> AwardResponse:
139
173
  """Query the MMS server for award results.
140
174
 
mms_client/types/base.py CHANGED
@@ -115,12 +115,9 @@ class SchemaType(Enum):
115
115
  OMI = "omi.xsd"
116
116
 
117
117
 
118
- class PayloadBase(BaseXmlModel, nsmap={"xsi": "http://www.w3.org/2001/XMLSchema-instance"}):
118
+ class PayloadBase(BaseXmlModel):
119
119
  """Represents the base fields for an MMS request payload."""
120
120
 
121
- # The XML schema to use for validation
122
- location: SchemaType = attr(name="noNamespaceSchemaLocation", ns="xsi")
123
-
124
121
 
125
122
  class BaseResponse(BaseXmlModel, Generic[E], tag="BaseResponse"):
126
123
  """Contains the base data extracted from the MMS response in a format we can use."""
@@ -0,0 +1,71 @@
1
+ """Contains objects for MMS reserve requirements."""
2
+
3
+ from typing import List
4
+ from typing import Optional
5
+
6
+ from pydantic_extra_types.pendulum_dt import DateTime
7
+ from pydantic_xml import attr
8
+ from pydantic_xml import element
9
+
10
+ from mms_client.types.base import Payload
11
+ from mms_client.types.enums import AreaCode
12
+ from mms_client.types.enums import Direction
13
+ from mms_client.types.fields import power_positive
14
+ from mms_client.types.market import MarketType
15
+
16
+
17
+ class Requirement(Payload):
18
+ """Represents a reserve requirement."""
19
+
20
+ # The start block of the requirement
21
+ start: DateTime = attr(name="StartTime")
22
+
23
+ # The end block of the requirement
24
+ end: DateTime = attr(name="EndTime")
25
+
26
+ # The direction of the requirement
27
+ direction: Direction = attr(name="Direction")
28
+
29
+ # The primary reserve quantity in kW
30
+ primary_qty_kw: Optional[int] = power_positive("PrimaryReserveQuantityInKw", True)
31
+
32
+ # The first secondary reserve quantity in kW
33
+ secondary_1_qty_kw: Optional[int] = power_positive("Secondary1ReserveQuantityInKw", True)
34
+
35
+ # The second secondary reserve quantity in kW
36
+ secondary_2_qty_kw: Optional[int] = power_positive("Secondary2ReserveQuantityInKw", True)
37
+
38
+ # The first tertiary reserve quantity in kW
39
+ tertiary_1_qty_kw: Optional[int] = power_positive("Tertiary1ReserveQuantityInKw", True)
40
+
41
+ # The second tertiary reserve quantity in kW
42
+ tertiary_2_qty_kw: Optional[int] = power_positive("Tertiary2ReserveQuantityInKw", True)
43
+
44
+ # The minimum reserve of compound primary and secondary 1 in kW
45
+ primary_secondary_1_qty_kw: Optional[int] = power_positive("CompoundPriSec1ReserveQuantityInKw", True)
46
+
47
+ # The minimum reserve of compound primary and secondary 2 in kW
48
+ primary_secondary_2_qty_kw: Optional[int] = power_positive("CompoundPriSec2ReserveQuantityInKw", True)
49
+
50
+ # The minimum reserve of compound primary and tertiary 1 in kW
51
+ primary_tertiary_1_qty_kw: Optional[int] = power_positive("CompoundPriTer1ReserveQuantityInKw", True)
52
+
53
+
54
+ class ReserveRequirement(Payload):
55
+ """Represents a set of reserve requirements."""
56
+
57
+ # The area for which the reserve requirement applies
58
+ area: AreaCode = attr(name="Area")
59
+
60
+ # The requirements associated with the area
61
+ requirements: List[Requirement] = element(tag="Requirement", min_length=1)
62
+
63
+
64
+ class ReserveRequirementQuery(Payload):
65
+ """Represents a request to query reserve requirements."""
66
+
67
+ # The market type for which to query reserve requirements
68
+ market_type: MarketType = attr(name="MarketType")
69
+
70
+ # The area for which to query reserve requirements
71
+ area: Optional[AreaCode] = attr(default=None, name="Area")
@@ -55,7 +55,7 @@ class Attachment(BaseModel):
55
55
  name: str = Field(alias="name")
56
56
 
57
57
  # The attachment file data
58
- data: bytes = Field(alias="binaryData")
58
+ data: str = Field(alias="binaryData")
59
59
 
60
60
 
61
61
  class MmsRequest(BaseModel):
@@ -83,7 +83,7 @@ class MmsRequest(BaseModel):
83
83
  signature: str = Field(alias="requestSignature")
84
84
 
85
85
  # The base-64 encoded payload of the request
86
- payload: bytes = Field(alias="requestData")
86
+ payload: str = Field(alias="requestData")
87
87
 
88
88
  # Any attached files to be sent with the request. Only 20 of these are allowed for OMI requests. For MI requests,
89
89
  # the limit is 40.
@@ -38,6 +38,21 @@ class AudienceError(ValueError):
38
38
  super().__init__(self.message)
39
39
 
40
40
 
41
+ class MMSServerError(RuntimeError):
42
+ """Error raised when the MMS server returns an error."""
43
+
44
+ def __init__(self, method: str, message: str):
45
+ """Initialize the error.
46
+
47
+ Arguments:
48
+ method (str): The method that caused the error.
49
+ message (str): The error message.
50
+ """
51
+ super().__init__(f"{method}: {message}")
52
+ self.message = message
53
+ self.method = method
54
+
55
+
41
56
  class MMSClientError(RuntimeError):
42
57
  """Base class for MMS client errors."""
43
58
 
@@ -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
@@ -80,7 +80,7 @@ class Serializer:
80
80
 
81
81
  # Finally, convert the payload to XML and return it
82
82
  # 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]
83
+ return self._to_canoncialized_xml(payload)
84
84
 
85
85
  def serialize_multi(self, request_envelope: E, request_data: List[P], request_type: Type[P]) -> bytes:
86
86
  """Serialize the envelope and data to a byte string for sending to the MMS server.
@@ -104,7 +104,7 @@ class Serializer:
104
104
 
105
105
  # Finally, convert the payload to XML and return it
106
106
  # 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]
107
+ return self._to_canoncialized_xml(payload)
108
108
 
109
109
  def deserialize(self, data: bytes, envelope_type: Type[E], data_type: Type[P]) -> Response[E, P]:
110
110
  """Deserialize the data to a response object.
@@ -132,6 +132,30 @@ class Serializer:
132
132
  tree = self._from_xml(data)
133
133
  return self._from_tree_multi(tree, envelope_type, data_type)
134
134
 
135
+ def _to_canoncialized_xml(self, payload: PayloadBase) -> bytes:
136
+ """Convert the payload to a canonicalized XML string.
137
+
138
+ Arguments:
139
+ payload (PayloadBase): The payload to be converted.
140
+
141
+ Returns: The canonicalized XML string.
142
+ """
143
+ # First, convert the payload to a raw XML string
144
+ raw: bytes = payload.to_xml(
145
+ skip_empty=True,
146
+ encoding="utf-8",
147
+ xml_declaration=False,
148
+ ) # type: ignore[assignment]
149
+
150
+ # Next, parse it back into an XML tree
151
+ unparsed = parse(BytesIO(raw))
152
+
153
+ # Finally, convert the XML tree to a canonicalized XML string and return it
154
+ buffer = BytesIO()
155
+ unparsed.write_c14n(buffer)
156
+ buffer.seek(0)
157
+ return buffer.read()
158
+
135
159
  def _from_tree(self, raw: Element, envelope_type: Type[E], data_type: Type[P]) -> Response[E, P]:
136
160
  """Convert the raw data to a response object.
137
161
 
mms_client/utils/web.py CHANGED
@@ -12,13 +12,13 @@ from requests import Session
12
12
  from requests_pkcs12 import Pkcs12Adapter
13
13
  from zeep import Client
14
14
  from zeep import Plugin
15
- from zeep import Transport
16
15
  from zeep.cache import SqliteCache
17
16
  from zeep.exceptions import TransportError
18
17
  from zeep.xsd.valueobjects import CompoundValue
19
18
 
20
19
  from mms_client.types.transport import MmsRequest
21
20
  from mms_client.types.transport import MmsResponse
21
+ from mms_client.utils.multipart_transport import MultipartTransport
22
22
 
23
23
  # Set the default logger for the MMS client
24
24
  logger = getLogger(__name__)
@@ -134,6 +134,7 @@ class ZWrapper:
134
134
 
135
135
  def __init__(
136
136
  self,
137
+ domain: str,
137
138
  client: ClientType,
138
139
  interface: Interface,
139
140
  adapter: Pkcs12Adapter,
@@ -144,6 +145,7 @@ class ZWrapper:
144
145
  """Create a new Zeep wrapper object for a specific MMS service endpoint.
145
146
 
146
147
  Arguments:
148
+ domain (str): The domain to use when signing the content ID to MTOM attachments.
147
149
  client (ClientType): The type of client to use. This can be either "bsp" (Balancing Service Provider) or
148
150
  "tso" (Transmission System Operator). This will determine which service endpoint to
149
151
  use.
@@ -191,13 +193,25 @@ class ZWrapper:
191
193
 
192
194
  # Finally, we create the Zeep client with the given WSDL file location, session, and cache settings and then,
193
195
  # from that client, we create the SOAP service with the given service binding and selected endpoint.
196
+ self._transport = MultipartTransport(domain, cache=SqliteCache() if cache else None, session=sess)
194
197
  self._client = Client(
195
198
  wsdl=str(location.resolve()),
196
- transport=Transport(cache=SqliteCache() if cache else None, session=sess),
199
+ transport=self._transport,
197
200
  plugins=plugins,
198
201
  )
199
202
  self._create_service()
200
203
 
204
+ def register_attachment(self, name: str, attachment: bytes) -> str:
205
+ """Register a multipart attachment.
206
+
207
+ Arguments:
208
+ name (str): The name of the attachment.
209
+ attachment (bytes): The data to be attached.
210
+
211
+ Returns: The content ID of the attachment, which should be used in place of the attachment data.
212
+ """
213
+ return self._transport.register_attachment(name, attachment)
214
+
201
215
  @on_exception(expo, TransportError, max_tries=3, giveup=fatal_code, logger=logger) # type: ignore[arg-type]
202
216
  def submit(self, req: MmsRequest) -> MmsResponse:
203
217
  """Submit the given request to the MMS server and return the response.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mms-client
3
- Version: 1.5.1
3
+ Version: 1.7.0
4
4
  Summary: API client for accessing the MMS
5
5
  Home-page: https://github.com/ElectroRoute-Japan/mms-client
6
6
  Author: Ryan Wood
@@ -22,6 +22,7 @@ Requires-Dist: backoff (>=2.2.1,<3.0.0)
22
22
  Requires-Dist: cryptography (>=42.0.5,<43.0.0)
23
23
  Requires-Dist: lxml (>=5.1.0,<6.0.0)
24
24
  Requires-Dist: pendulum (>=3.0.0,<4.0.0)
25
+ Requires-Dist: pycryptodomex (>=3.20.0,<4.0.0)
25
26
  Requires-Dist: pydantic (>=2.6.3,<3.0.0)
26
27
  Requires-Dist: pydantic-xml (>=2.9.0,<3.0.0)
27
28
  Requires-Dist: requests (>=2.31.0,<3.0.0)
@@ -43,6 +44,9 @@ The underlying API sends and receives XML documents. Each of these request or re
43
44
 
44
45
  After the data has been converted and added to the outer request object, it is sent to the appropriate server endpoint via a Zeep client. The client certificate is also injected into the request using a PCKS12 adaptor.
45
46
 
47
+ ## Domain
48
+ The domain to use when signing the content ID to MTOM attachments is specified when creating the client. This is not verified by the server, but it is used to generate the content ID for the MTOM attachments. As such, it is important to ensure that the domain is correct.
49
+
46
50
  # Serialization
47
51
  This library relies on Pydantic 2 and the pydantic-xml library for serialization/deserialization. As such, any type in this library can be converted to not only XML, but to JSON as well. This is extremely useful if you're trying to build a pass-through API service or something similar.
48
52
 
@@ -142,7 +146,7 @@ from mms_client.utils.web import ClientType
142
146
  cert = Certificate("/path/to/my/cert.p12", "fake_passphrase")
143
147
 
144
148
  # Create a new MMS client
145
- client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert)
149
+ client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert)
146
150
 
147
151
  # Create our request offer
148
152
  request_offer = OfferData(
@@ -176,14 +180,14 @@ There's a lot of code here but it's not terribly difficult to understand. All th
176
180
  If you want to test your MMS connection, you can try using the test server:
177
181
 
178
182
  ```python
179
- client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, test=True)
183
+ client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, test=True)
180
184
  ```
181
185
 
182
186
  ## Connecting as a Market Admin
183
187
  If you're connecting as a market operator (MO), you can connect in admin mode:
184
188
 
185
189
  ```python
186
- client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, is_admin=True)
190
+ client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, is_admin=True)
187
191
  ```
188
192
 
189
193
  ## Auditing XML Requests & Responses
@@ -202,13 +206,14 @@ class TestAuditPlugin(AuditPlugin):
202
206
  def audit_response(self, mms_response: bytes) -> None:
203
207
  self.response = mms_response
204
208
 
205
- client = MmsClient(participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, plugins=[TestAuditPlugin()])
209
+ client = MmsClient(domain="mydomain.com", participant="F100", user="FAKEUSER", client_type=ClientType.BSP, cert, plugins=[TestAuditPlugin()])
206
210
  ```
207
211
 
208
212
  This same input allows for the user to create their own plugins and add them to the Zeep client, allowing for a certain amount of extensibility.
209
213
 
210
214
  # Completeness
211
215
  This client is not complete. Currently, it supports the following endpoints:
216
+ - MarketQuery_ReserveRequirementQuery
212
217
  - MarketSubmit_OfferData
213
218
  - MarketQuery_OfferQuery
214
219
  - MarketCancel_OfferCancel
@@ -9,30 +9,32 @@ mms_client/schemas/xsd/mi-report.xsd,sha256=XEHhHCGgK4aeYsmObIrlzkvV8UhbirgoysAE
9
9
  mms_client/schemas/xsd/mpr.xsd,sha256=QcnuKFm1WkyZfT_56cINHldZkR-3pU4nHSbFKHkIQS0,69149
10
10
  mms_client/schemas/xsd/omi.xsd,sha256=benkYeno_HF6BOIv7gAT79dWz00JccS3XilPLBCKVIU,31524
11
11
  mms_client/security/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
- mms_client/security/certs.py,sha256=kNCUFmy18YIxkWKu3mdMmlxmHdft4a6BvtyJ46rA9I4,1489
13
- mms_client/security/crypto.py,sha256=M7aIllM3_ZwZgm9nH6QQ6Ig14XCAd6e6WGwqqUbbI1Q,2149
12
+ mms_client/security/certs.py,sha256=Gy-CuSsdLPFeoPH_sEYhY67dI5sy6yJ8iTwlysRKT1s,3018
13
+ mms_client/security/crypto.py,sha256=u9Z6nkAW6LbBqUzjIEbZ-CcqdkMJ9fqvdX7IXTTh1EI,2345
14
14
  mms_client/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
- mms_client/services/base.py,sha256=kI--NGcxYP9-cF5PHolSGOT-XtBFcjmlsRLxpd48GVE,25897
16
- mms_client/services/market.py,sha256=XCD49lILwf7_hpF54MpQFsO-NBK4PwmNznsoMyg23mo,7684
15
+ mms_client/services/base.py,sha256=T6D9vDsNmz68rLAVbzCl9WQAHqYQ7D18JVFi-L_LkSs,27914
16
+ mms_client/services/market.py,sha256=hQnMZ52abjcQO3L0d5bPuJz7hmBEAbDq2l12I9YUG7k,9153
17
17
  mms_client/services/omi.py,sha256=UG1zYkFz0sFsEbhE6P0CLoAOZZOyEshkZ_b7D_e3CjQ,626
18
18
  mms_client/services/registration.py,sha256=ryj2WKJoBzRU1r6Svbl2MyGOG0H5aEYAK6HQWL9kYaA,3815
19
19
  mms_client/services/report.py,sha256=ZXYDaknPCnSYZI857QHbkzst1Pez_PrKFudVF9bQB7Q,642
20
20
  mms_client/types/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
21
21
  mms_client/types/award.py,sha256=BWE9V_KHXpg_cW1LZsetVrPs2hZDOklvRpNnoZtmR3k,14139
22
- mms_client/types/base.py,sha256=VQCr50CL1SnnPcO1EYGHa4rrkHBtXs-J8psWLJWoHJY,9710
22
+ mms_client/types/base.py,sha256=YMRGCBBDMGRlR7YpDBSDVDvhjEJrxA32YhmiHoXXz-Q,9530
23
23
  mms_client/types/enums.py,sha256=YJ58FbhyQ0TVlf8Z-Dg1UfVu8CurY5b21Cy5-WYkJ0I,1629
24
24
  mms_client/types/fields.py,sha256=pa5qvQVwEr8dh44IGHyYqgJYTYyTIeAjBW6CylXrkP0,14785
25
25
  mms_client/types/market.py,sha256=IbXsH4Q5MJI-CEvGvZlzv2S36mX_Ea02U11Ik-NwSxQ,2706
26
26
  mms_client/types/offer.py,sha256=KosFiKRMnt7XwlLBUfjHUGHiWzrMJUPPhGQMxgdeepM,6791
27
27
  mms_client/types/registration.py,sha256=Nir73S3ffpk0O_fnTD2alFaqV1k67_8dcyyduXvPBI4,1381
28
+ mms_client/types/reserve.py,sha256=pLV47w_749EIVhj0tUuJdWdHBBEl0-v10oVioccgxnU,2667
28
29
  mms_client/types/resource.py,sha256=TQnY3JLHRgQhQrG6ISquw-BQgKSr8TGuqn9ItWxWz_w,65974
29
- mms_client/types/transport.py,sha256=DPjWs34UW915GkUCJWKuDZmsjS6mRdRXgcGISduN_Bc,4399
30
+ mms_client/types/transport.py,sha256=PZ7mDKeH8rGOVONk0ZH5herft71PFF-MUpp3uB57WXo,4395
30
31
  mms_client/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
31
32
  mms_client/utils/auditing.py,sha256=JDcvNo4ul66xPtDeeocn568yIe8fhh-jM11MWP-Kfes,2057
32
- mms_client/utils/errors.py,sha256=ovoJjrvoVkbMRhHcq3HR9p1uH_65QicvaGy_7wNv7JI,2579
33
- mms_client/utils/serialization.py,sha256=k0_fBm-yoRZV2AMiickSyauoDyA8i7uIPU6JjfQWx4Q,29638
34
- mms_client/utils/web.py,sha256=-abdVxSi7c6xQYsZObbj0yXwGI5VWthr5KtWDyLBCM4,10156
35
- mms_client-1.5.1.dist-info/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
36
- mms_client-1.5.1.dist-info/METADATA,sha256=KbSz2DFw9HLmP1lneI96S21Rh1d4GPpNuPitW-FGe-k,15987
37
- mms_client-1.5.1.dist-info/WHEEL,sha256=Zb28QaM1gQi8f4VCBhsUklF61CTlNYfs9YAZn-TOGFk,88
38
- mms_client-1.5.1.dist-info/RECORD,,
33
+ mms_client/utils/errors.py,sha256=6k-NOjGZyTbTUISzN7B4JrmU2P8cwjpFFmFC7kJOQFQ,3005
34
+ mms_client/utils/multipart_transport.py,sha256=O374vPh2j29_CSjkWnIaPJfiabRSGNpQvNQcUODdkDM,8871
35
+ mms_client/utils/serialization.py,sha256=kGPOWqOxHJ4IPyVB2xRiG6cKMqhUBSNBWJ4bF6xbxH8,30293
36
+ mms_client/utils/web.py,sha256=7c6Ghs3Y52cm2ge-9svR39uQjr2Pm2LhX9Wz-S1APa4,10816
37
+ mms_client-1.7.0.dist-info/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
38
+ mms_client-1.7.0.dist-info/METADATA,sha256=HWPxyyd84Fr9HnM7058QKp_yQRsnw0l5sefy8gb2yto,16447
39
+ mms_client-1.7.0.dist-info/WHEEL,sha256=Zb28QaM1gQi8f4VCBhsUklF61CTlNYfs9YAZn-TOGFk,88
40
+ mms_client-1.7.0.dist-info/RECORD,,