shapeshifter-uftp 1.0.0__tar.gz

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.
Files changed (52) hide show
  1. shapeshifter-uftp-1.0.0/PKG-INFO +12 -0
  2. shapeshifter-uftp-1.0.0/README.rst +28 -0
  3. shapeshifter-uftp-1.0.0/setup.cfg +4 -0
  4. shapeshifter-uftp-1.0.0/setup.py +40 -0
  5. shapeshifter-uftp-1.0.0/shapeshifter_uftp/__init__.py +145 -0
  6. shapeshifter-uftp-1.0.0/shapeshifter_uftp/cli.py +59 -0
  7. shapeshifter-uftp-1.0.0/shapeshifter_uftp/client/__init__.py +20 -0
  8. shapeshifter-uftp-1.0.0/shapeshifter_uftp/client/agr_cro_client.py +29 -0
  9. shapeshifter-uftp-1.0.0/shapeshifter_uftp/client/agr_dso_client.py +92 -0
  10. shapeshifter-uftp-1.0.0/shapeshifter_uftp/client/base_client.py +224 -0
  11. shapeshifter-uftp-1.0.0/shapeshifter_uftp/client/cro_agr_client.py +25 -0
  12. shapeshifter-uftp-1.0.0/shapeshifter_uftp/client/cro_dso_client.py +33 -0
  13. shapeshifter-uftp-1.0.0/shapeshifter_uftp/client/dso_agr_client.py +90 -0
  14. shapeshifter-uftp-1.0.0/shapeshifter_uftp/client/dso_cro_client.py +25 -0
  15. shapeshifter-uftp-1.0.0/shapeshifter_uftp/exceptions.py +269 -0
  16. shapeshifter-uftp-1.0.0/shapeshifter_uftp/logging.py +31 -0
  17. shapeshifter-uftp-1.0.0/shapeshifter_uftp/service/__init__.py +10 -0
  18. shapeshifter-uftp-1.0.0/shapeshifter_uftp/service/agr_service.py +352 -0
  19. shapeshifter-uftp-1.0.0/shapeshifter_uftp/service/base_service.py +301 -0
  20. shapeshifter-uftp-1.0.0/shapeshifter_uftp/service/cro_service.py +123 -0
  21. shapeshifter-uftp-1.0.0/shapeshifter_uftp/service/dso_service.py +298 -0
  22. shapeshifter-uftp-1.0.0/shapeshifter_uftp/transport.py +258 -0
  23. shapeshifter-uftp-1.0.0/shapeshifter_uftp/uftp/__init__.py +173 -0
  24. shapeshifter-uftp-1.0.0/shapeshifter_uftp/uftp/agr_cro.py +302 -0
  25. shapeshifter-uftp-1.0.0/shapeshifter_uftp/uftp/agr_dso.py +1151 -0
  26. shapeshifter-uftp-1.0.0/shapeshifter_uftp/uftp/common.py +214 -0
  27. shapeshifter-uftp-1.0.0/shapeshifter_uftp/uftp/cro_dso.py +318 -0
  28. shapeshifter-uftp-1.0.0/shapeshifter_uftp/uftp/defaults.py +1 -0
  29. shapeshifter-uftp-1.0.0/shapeshifter_uftp/uftp/metering.py +196 -0
  30. shapeshifter-uftp-1.0.0/shapeshifter_uftp/uftp/validations.py +28 -0
  31. shapeshifter-uftp-1.0.0/shapeshifter_uftp.egg-info/PKG-INFO +12 -0
  32. shapeshifter-uftp-1.0.0/shapeshifter_uftp.egg-info/SOURCES.txt +50 -0
  33. shapeshifter-uftp-1.0.0/shapeshifter_uftp.egg-info/dependency_links.txt +1 -0
  34. shapeshifter-uftp-1.0.0/shapeshifter_uftp.egg-info/entry_points.txt +4 -0
  35. shapeshifter-uftp-1.0.0/shapeshifter_uftp.egg-info/requires.txt +16 -0
  36. shapeshifter-uftp-1.0.0/shapeshifter_uftp.egg-info/top_level.txt +1 -0
  37. shapeshifter-uftp-1.0.0/test/test_cli.py +30 -0
  38. shapeshifter-uftp-1.0.0/test/test_client_errors.py +29 -0
  39. shapeshifter-uftp-1.0.0/test/test_client_with_workers.py +84 -0
  40. shapeshifter-uftp-1.0.0/test/test_clients_from_service.py +31 -0
  41. shapeshifter-uftp-1.0.0/test/test_communications.py +54 -0
  42. shapeshifter-uftp-1.0.0/test/test_default_responses.py +51 -0
  43. shapeshifter-uftp-1.0.0/test/test_message_destination.py +43 -0
  44. shapeshifter-uftp-1.0.0/test/test_presence_of_client_methods.py +16 -0
  45. shapeshifter-uftp-1.0.0/test/test_presence_of_service_methods.py +16 -0
  46. shapeshifter-uftp-1.0.0/test/test_roundtrip_serialization.py +21 -0
  47. shapeshifter-uftp-1.0.0/test/test_seal_and_unseal.py +62 -0
  48. shapeshifter-uftp-1.0.0/test/test_service_errors.py +86 -0
  49. shapeshifter-uftp-1.0.0/test/test_snake_case.py +10 -0
  50. shapeshifter-uftp-1.0.0/test/test_ttl_cache.py +47 -0
  51. shapeshifter-uftp-1.0.0/test/test_validations.py +39 -0
  52. shapeshifter-uftp-1.0.0/test/test_xml_schema_compliance.py +21 -0
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.1
2
+ Name: shapeshifter-uftp
3
+ Version: 1.0.0
4
+ Summary: Allows connections between DSO, AGR and CRO using the Shapeshifter (UFTP) protocol.
5
+ Home-page: UNKNOWN
6
+ License: UNKNOWN
7
+ Platform: UNKNOWN
8
+ Requires-Python: >=3.10
9
+ Provides-Extra: dev
10
+
11
+ UNKNOWN
12
+
@@ -0,0 +1,28 @@
1
+ Shapeshifter library for Python
2
+ ===============================
3
+
4
+ This is a Python implementation of the ShapeShifter UFTP protocol.
5
+
6
+ Installation
7
+ ------------
8
+
9
+ .. code-block:: python3
10
+
11
+ pip install shapeshifter-uftp
12
+
13
+
14
+ Overview
15
+ --------
16
+
17
+ This library implements the full UFTP protocol that you can use for Shapeshifter communications. It implements all three roles: Distribution System Operator (**DSO**), Aggregator (**AGR**) and Common Reference Operator (**CRO**) in both directions (client and service).
18
+
19
+ Features of this package:
20
+
21
+ - Building, parsing and validation of the XML messages
22
+ - Signing and verifying of the XML messages using signatures
23
+ - DNS for service discovery and key retrieval
24
+ - Convenient clients for each role-pair
25
+ - Convenient services for each role
26
+ - JSON-serializable dataclasses for easy transport to other systems
27
+ - Fully internal queing system for full-duplex communication with minimal user code required
28
+
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,40 @@
1
+ from setuptools import setup
2
+
3
+ setup(
4
+ name="shapeshifter-uftp",
5
+ version="1.0.0",
6
+ python_requires=">=3.10",
7
+ description="Allows connections between DSO, AGR and CRO using the Shapeshifter (UFTP) protocol.",
8
+ packages=[
9
+ "shapeshifter_uftp",
10
+ "shapeshifter_uftp.uftp",
11
+ "shapeshifter_uftp.client",
12
+ "shapeshifter_uftp.service",
13
+ ],
14
+ install_requires=[
15
+ "xsdata[lxml]",
16
+ "pynacl",
17
+ "dnspython",
18
+ "fastapi<0.100",
19
+ "fastapi-xml",
20
+ "requests",
21
+ "uvicorn",
22
+ "termcolor",
23
+ ],
24
+ extras_require={
25
+ "dev": [
26
+ "xmlschema",
27
+ "pytest",
28
+ "pytest-cov",
29
+ "pylint",
30
+ "sphinx",
31
+ "sphinx-rtd-theme",
32
+ ]
33
+ },
34
+ entry_points={
35
+ "console_scripts": [
36
+ "shapeshifter-keypair = shapeshifter_uftp.cli:generate_signing_keypair",
37
+ "shapeshifter-lookup = shapeshifter_uftp.cli:perform_lookup",
38
+ ]
39
+ },
40
+ )
@@ -0,0 +1,145 @@
1
+ from .client import (
2
+ ShapeshifterAgrCroClient,
3
+ ShapeshifterAgrDsoClient,
4
+ ShapeshifterCroAgrClient,
5
+ ShapeshifterCroDsoClient,
6
+ ShapeshifterDsoAgrClient,
7
+ ShapeshifterDsoCroClient,
8
+ )
9
+ from .service import (
10
+ ShapeshifterAgrService,
11
+ ShapeshifterDsoService,
12
+ ShapeshifterCroService,
13
+ )
14
+ from .uftp import (
15
+ AcceptedRejected,
16
+ AgrPortfolioQuery,
17
+ AgrPortfolioQueryResponse,
18
+ AgrPortfolioQueryResponseCongestionPoint,
19
+ AgrPortfolioQueryResponseConnection,
20
+ AgrPortfolioQueryResponseDSOPortfolio,
21
+ AgrPortfolioQueryResponseDSOView,
22
+ AgrPortfolioUpdate,
23
+ AgrPortfolioUpdateConnection,
24
+ AgrPortfolioUpdateResponse,
25
+ ContractSettlement,
26
+ ContractSettlementISP,
27
+ ContractSettlementPeriod,
28
+ DPrognosis,
29
+ DPrognosisISP,
30
+ DPrognosisResponse,
31
+ DsoPortfolioQuery,
32
+ DsoPortfolioQueryCongestionPoint,
33
+ DsoPortfolioQueryConnection,
34
+ DsoPortfolioQueryResponse,
35
+ DsoPortfolioUpdate,
36
+ DsoPortfolioUpdateCongestionPoint,
37
+ DsoPortfolioUpdateConnection,
38
+ DsoPortfolioUpdateResponse,
39
+ FlexMessage,
40
+ FlexOffer,
41
+ FlexOfferOption,
42
+ FlexOfferOptionISP,
43
+ FlexOfferResponse,
44
+ FlexOfferRevocation,
45
+ FlexOfferRevocationResponse,
46
+ FlexOrder,
47
+ FlexOrderISP,
48
+ FlexOrderResponse,
49
+ FlexOrderSettlement,
50
+ FlexOrderSettlementISP,
51
+ FlexOrderSettlementStatus,
52
+ FlexOrderStatus,
53
+ FlexRequest,
54
+ FlexRequestISP,
55
+ FlexRequestResponse,
56
+ FlexReservationUpdate,
57
+ FlexReservationUpdateISP,
58
+ FlexReservationUpdateResponse,
59
+ FlexSettlement,
60
+ FlexSettlementResponse,
61
+ Metering,
62
+ MeteringISP,
63
+ # MeteringMessage,
64
+ MeteringProfile,
65
+ MeteringProfileEnum,
66
+ MeteringResponse,
67
+ MeteringUnit,
68
+ PayloadMessage,
69
+ PayloadMessageResponse,
70
+ SignedMessage,
71
+ TestMessage,
72
+ TestMessageResponse,
73
+ UsefRole,
74
+ )
75
+
76
+ __all__ = [
77
+ "ShapeshifterAgrCroClient",
78
+ "ShapeshifterAgrDsoClient",
79
+ "ShapeshifterCroAgrClient",
80
+ "ShapeshifterCroDsoClient",
81
+ "ShapeshifterDsoAgrClient",
82
+ "ShapeshifterDsoCroClient",
83
+ "ShapeshifterAgrService",
84
+ "ShapeshifterDsoService",
85
+ "ShapeshifterCroService",
86
+ "AcceptedRejected",
87
+ "AgrPortfolioQuery",
88
+ "AgrPortfolioQueryResponse",
89
+ "AgrPortfolioQueryResponseCongestionPoint",
90
+ "AgrPortfolioQueryResponseConnection",
91
+ "AgrPortfolioQueryResponseDSOPortfolio",
92
+ "AgrPortfolioQueryResponseDSOView",
93
+ "AgrPortfolioUpdate",
94
+ "AgrPortfolioUpdateConnection",
95
+ "AgrPortfolioUpdateResponse",
96
+ "ContractSettlement",
97
+ "ContractSettlementISP",
98
+ "ContractSettlementPeriod",
99
+ "DPrognosis",
100
+ "DPrognosisISP",
101
+ "DPrognosisResponse",
102
+ "DsoPortfolioQuery",
103
+ "DsoPortfolioQueryCongestionPoint",
104
+ "DsoPortfolioQueryConnection",
105
+ "DsoPortfolioQueryResponse",
106
+ "DsoPortfolioUpdate",
107
+ "DsoPortfolioUpdateCongestionPoint",
108
+ "DsoPortfolioUpdateConnection",
109
+ "DsoPortfolioUpdateResponse",
110
+ "FlexMessage",
111
+ "FlexOffer",
112
+ "FlexOfferOption",
113
+ "FlexOfferOptionISP",
114
+ "FlexOfferResponse",
115
+ "FlexOfferRevocation",
116
+ "FlexOfferRevocationResponse",
117
+ "FlexOrder",
118
+ "FlexOrderISP",
119
+ "FlexOrderResponse",
120
+ "FlexOrderSettlement",
121
+ "FlexOrderSettlementISP",
122
+ "FlexOrderSettlementStatus",
123
+ "FlexOrderStatus",
124
+ "FlexRequest",
125
+ "FlexRequestISP",
126
+ "FlexRequestResponse",
127
+ "FlexReservationUpdate",
128
+ "FlexReservationUpdateISP",
129
+ "FlexReservationUpdateResponse",
130
+ "FlexSettlement",
131
+ "FlexSettlementResponse",
132
+ "Metering",
133
+ "MeteringISP",
134
+ # "MeteringMessage",
135
+ "MeteringProfile",
136
+ "MeteringProfileEnum",
137
+ "MeteringResponse",
138
+ "MeteringUnit",
139
+ "PayloadMessage",
140
+ "PayloadMessageResponse",
141
+ "SignedMessage",
142
+ "TestMessage",
143
+ "TestMessageResponse",
144
+ "UsefRole",
145
+ ]
@@ -0,0 +1,59 @@
1
+ """
2
+ A set of command-line-interface functions that are useful during
3
+ development of Shapeshifter applications.
4
+ """
5
+
6
+ from argparse import ArgumentParser
7
+ from base64 import b64encode
8
+ from nacl.bindings import crypto_sign_keypair
9
+ from . import transport
10
+ from .exceptions import ServiceDiscoveryException, AuthenticationTimeoutException
11
+
12
+ def generate_signing_keypair():
13
+ """
14
+ Generate a signing keypair (private and public) and print them as
15
+ base64-encoded strings. These are the strings that you'd use for
16
+ signing and verifying messages; you pass these to the signing_key
17
+ and recipient_signing_key parameters of the Service or Client
18
+ objects.
19
+ """
20
+ public, private = crypto_sign_keypair()
21
+ print("-" * 66)
22
+ print("Private key (base64):", b64encode(private).decode())
23
+ print("Public key (base64): ", b64encode(public).decode())
24
+ print("-" * 66)
25
+
26
+ def perform_lookup():
27
+ """
28
+ Perform a DNS lookup of a participant's version, endpoint and
29
+ public key details. These use the well-known DNS names described
30
+ in the UFTP specification.
31
+ """
32
+ parser = ArgumentParser()
33
+ parser.add_argument("-d", "--domain", required=True, type=str, help="The sender domain for the other party")
34
+ parser.add_argument("-r", "--role", required=True, type=str, help="The sender role for the other party")
35
+ args = parser.parse_args()
36
+
37
+ print("-" * 65)
38
+
39
+ try:
40
+ version = transport.get_version(args.domain)
41
+ print(f"Shapeshifer version: {version}")
42
+ except ServiceDiscoveryException as err:
43
+ print(err)
44
+
45
+ try:
46
+ endpoint = transport.get_endpoint(args.domain, args.role)
47
+ print(f"Endpoint URL: {endpoint}")
48
+ except ServiceDiscoveryException as err:
49
+ print(err)
50
+
51
+ try:
52
+ signing_key, decryption_key = transport.get_keys(args.domain, args.role)
53
+ print(f"Signing key: {signing_key}")
54
+ if decryption_key:
55
+ print(f"Decryption Key: {decryption_key}")
56
+ except AuthenticationTimeoutException as err:
57
+ print(err)
58
+
59
+ print("-" * 65)
@@ -0,0 +1,20 @@
1
+ from .agr_cro_client import ShapeshifterAgrCroClient
2
+ from .agr_dso_client import ShapeshifterAgrDsoClient
3
+ from .cro_agr_client import ShapeshifterCroAgrClient
4
+ from .cro_dso_client import ShapeshifterCroDsoClient
5
+ from .dso_agr_client import ShapeshifterDsoAgrClient
6
+ from .dso_cro_client import ShapeshifterDsoCroClient
7
+
8
+ __all__ = [
9
+ "ShapeshifterAgrCroClient",
10
+ "ShapeshifterAgrDsoClient",
11
+ "ShapeshifterCroAgrClient",
12
+ "ShapeshifterCroDsoClient",
13
+ "ShapeshifterDsoAgrClient",
14
+ "ShapeshifterDsoCroClient"
15
+ ]
16
+
17
+ client_map = {
18
+ (client.sender_role, client.recipient_role): client
19
+ for client in [globals()[name] for name in __all__]
20
+ }
@@ -0,0 +1,29 @@
1
+ from .base_client import ShapeshifterClient
2
+ from ..uftp import (
3
+ PayloadMessageResponse,
4
+ AgrPortfolioQuery,
5
+ AgrPortfolioUpdate,
6
+ )
7
+
8
+
9
+ class ShapeshifterAgrCroClient(ShapeshifterClient):
10
+ """
11
+ Client that allows the Aggregator to connect to the CRO.
12
+ """
13
+
14
+ sender_role = "AGR"
15
+ recipient_role = "CRO"
16
+
17
+ def send_agr_portfolio_update(self, message: AgrPortfolioUpdate) -> PayloadMessageResponse:
18
+ """
19
+ The AGRPortfolioUpdate is used by the AGR to indicate on which
20
+ Connections it represents prosumers.
21
+ """
22
+ return self._send_message(message)
23
+
24
+ def send_agr_portfolio_query(self, message: AgrPortfolioQuery) -> PayloadMessageResponse:
25
+ """
26
+ The AGRPortfolioQuery is used by the AGR to retrieve additional
27
+ information on the connections.
28
+ """
29
+ return self._send_message(message)
@@ -0,0 +1,92 @@
1
+ from .base_client import ShapeshifterClient
2
+ from ..uftp import (
3
+ DPrognosis,
4
+ FlexRequestResponse,
5
+ FlexOffer,
6
+ FlexOfferRevocation,
7
+ FlexOrderResponse,
8
+ FlexSettlementResponse,
9
+ FlexReservationUpdateResponse,
10
+ Metering,
11
+ PayloadMessageResponse
12
+ )
13
+
14
+
15
+ class ShapeshifterAgrDsoClient(ShapeshifterClient):
16
+ """
17
+ Client that allows the Aggregator to connect to the DSO.
18
+ """
19
+
20
+ sender_role = "AGR"
21
+ recipient_role = "DSO"
22
+
23
+ def send_d_prognosis(self, message: DPrognosis) -> PayloadMessageResponse:
24
+ """
25
+ D-Prognosis messages are used to communicate D-prognoses between AGRs
26
+ and DSOs. D-Prognosis messages always contain data for all ISPs for the
27
+ period they apply to, even if a prognosis is sent after the start of
28
+ the period, when one or more ISPs are already in the operate or
29
+ settlement phase. Receiving implementations should ignore the
30
+ information supplied for those ISPs.
31
+ """
32
+ return self._send_message(message)
33
+
34
+ def send_flex_request_response(self, message: FlexRequestResponse) -> PayloadMessageResponse:
35
+ """
36
+ Upon receiving and processing a FlexRequest message, the receiving
37
+ implementation must reply with a FlexRequestResponse, indicating
38
+ whether the flexibility request was processed successfully.
39
+ """
40
+ return self._send_message(message)
41
+
42
+ def send_flex_offer(self, message: FlexOffer) -> PayloadMessageResponse:
43
+ """
44
+ FlexOffer messages are used by AGRs to make DSOs an offer for provision
45
+ of flexibility. A FlexOffer message contains a list of ISPs and, for
46
+ each ISP, the change in consumption or production offered and the price
47
+ for the total amount of flexibility offered. FlexOffer messages can be
48
+ sent once a FlexRequest message has been received but can also be sent
49
+ unsolicited. Note that multiple FlexOffer messages may be sent based on
50
+ a single FlexRequest, e.g. to increase the chance that the DSO will
51
+ order at least part of its available flexibility. The AGR must make
52
+ sure that it can actually provide the flexibility offered across all of
53
+ its FlexOffers.
54
+ """
55
+ return self._send_message(message)
56
+
57
+ def send_flex_offer_revocation(self, message: FlexOfferRevocation) -> PayloadMessageResponse:
58
+ """
59
+ The FlexOfferRevocation message is used by the AGR to revoke a FlexOffer
60
+ previously sent to a DSO. It voids the FlexOffer, even if its validity
61
+ time has not yet expired. Revocation is not allowed for FlexOffers that
62
+ already have associated accepted FlexOrders.
63
+ """
64
+ return self._send_message(message)
65
+
66
+ def send_flex_order_response(self, message: FlexOrderResponse) -> PayloadMessageResponse:
67
+ """
68
+ Confirm the flex order.
69
+ """
70
+ return self._send_message(message)
71
+
72
+ def send_flex_settlement_response(self, message: FlexSettlementResponse) -> PayloadMessageResponse:
73
+ """
74
+ Upon receiving and processing a FlexSettlement message, the AGR must
75
+ reply with a FlexSettlementResponse, indicating whether the initial
76
+ message was handled successfully. When a FlexSettlement message is
77
+ rejected, the DSO should consider all FlexOrderSettlement elements of
78
+ that message related to potential dispute.
79
+ """
80
+ return self._send_message(message)
81
+
82
+ def send_flex_reservation_update_response(self, message: FlexReservationUpdateResponse) -> PayloadMessageResponse:
83
+ """
84
+ Confirm the flex reservation update.
85
+ """
86
+ return self._send_message(message)
87
+
88
+ def send_metering(self, message: Metering) -> PayloadMessageResponse:
89
+ """
90
+ Send metering data to the DSO.
91
+ """
92
+ return self._send_message(message)
@@ -0,0 +1,224 @@
1
+ from datetime import datetime, timezone
2
+ import sched
3
+ import time
4
+ from queue import Queue
5
+ from threading import Thread, Event
6
+ from uuid import uuid4
7
+ import requests
8
+ from ..uftp import PayloadMessage, PayloadMessageResponse, SignedMessage
9
+ from ..logging import logger
10
+ from ..exceptions import ClientTransportException
11
+ from .. import transport
12
+
13
+
14
+ class ShapeshifterClient():
15
+ """
16
+ Basis for all Shapeshifter client.
17
+ """
18
+
19
+ sender_role: str
20
+ recipient_role: str
21
+ protocol_version = "3.0.0"
22
+ num_outgoing_workers = 10
23
+ num_delivery_attempts = 10
24
+ request_timeout = 30
25
+ exponential_retry_factor = 1
26
+ exponential_retry_base = 2
27
+
28
+ def __init__(
29
+ self,
30
+ sender_domain: str,
31
+ signing_key: str,
32
+ recipient_domain: str,
33
+ recipient_endpoint: str = None,
34
+ recipient_signing_key: str = None,
35
+ ):
36
+ """
37
+ Shapeshifter client class that allows you to initiate messages to a different party.
38
+ :param sender_domain: your sender domain
39
+ :param signing_key: your private signing key
40
+ :param recipient_domain: the domain of the recipient
41
+ :param recipient_endpoint: the full http endpoint URL of the recipient. If omitted,
42
+ will look up the endpoint using DNS.
43
+ :recipient_signing_key: the public signing key of the recipient. If omitted, will
44
+ look up the signing key using DNS.
45
+ """
46
+ if recipient_domain is None and recipient_endpoint is None:
47
+ raise ValueError(
48
+ "One of recipient_domain or recipient_endpoint must be provided."
49
+ )
50
+
51
+ self.sender_domain = sender_domain
52
+ self.signing_key = signing_key
53
+ self.recipient_domain = recipient_domain
54
+ self.recipient_endpoint = recipient_endpoint
55
+ self.recipient_signing_key = recipient_signing_key
56
+
57
+ # The outgoing queue and scheduler are used when queueing
58
+ # messages for delivery later. This allows the Shapeshifter
59
+ # UFTP client to handle message retries on an exponential
60
+ # time schedule, and delivers the result in the provided
61
+ # callback function.
62
+ self.outgoing_queue = Queue()
63
+ self.outgoing_workers = None
64
+ self.scheduler = sched.scheduler(time.monotonic, time.sleep)
65
+ self.scheduler_event = Event()
66
+ self.scheduler_thread = None
67
+
68
+ def _send_message(self, message: PayloadMessage) -> PayloadMessageResponse:
69
+ """
70
+ Perform an operation. This will take the message object, pack
71
+ it up into a SignedMessage, sign and seal it, and send it to
72
+ the recipient. It returns an unsealed PayloadMessageResponse
73
+ that contains the functional status of the request. The
74
+ actual response always arries asynchronously on your service
75
+ (which runs separately).
76
+ """
77
+ if not isinstance(message, PayloadMessage):
78
+ raise TypeError(
79
+ f"'message' must be a (subclass of) PayloadMessage, you provided: {type(message)}"
80
+ )
81
+
82
+ # Fill the PayloadMessage's fields that are common to all
83
+ # messages. We don't require the developer to fill these out
84
+ # every time they create any message, in order to reduce the
85
+ # duplicated code that would result in, and all of these
86
+ # properties can be calculated in the framework anyway.
87
+ message.version = self.protocol_version
88
+ message.sender_domain = self.sender_domain
89
+ message.sender_role = self.sender_role
90
+ message.recipient_domain = self.recipient_domain
91
+ message.time_stamp = message.time_stamp or datetime.now(timezone.utc).isoformat()
92
+ message.message_id = message.message_id or str(uuid4())
93
+ message.conversation_id = message.conversation_id or str(uuid4())
94
+
95
+ logger.info(f"The PayloadMessage is: {message}")
96
+
97
+ # Seal the message using our own private signing key
98
+ sealed_message = transport.seal_message(message, self.signing_key)
99
+
100
+ # Pack up the message into a SignedMessage
101
+ signed_message = SignedMessage(
102
+ sender_domain=self.sender_domain,
103
+ sender_role=self.sender_role,
104
+ body=sealed_message
105
+ )
106
+
107
+ # Serialize the message into an XML blob
108
+ serialized_message = transport.to_xml(signed_message)
109
+
110
+ logger.debug(f"Sending message to {self.recipient_endpoint}:")
111
+ logger.debug(serialized_message)
112
+
113
+ # Send the request to the relevant endpoint
114
+ response = requests.post(
115
+ self.recipient_endpoint,
116
+ data=serialized_message,
117
+ headers={"Content-Type": "text/xml; charset=utf-8"},
118
+ timeout=self.request_timeout,
119
+ )
120
+ if response.status_code != 200:
121
+ error_msg = (
122
+ f"Request to {self.recipient_endpoint} was not succesful: "
123
+ f"HTTP {response.status_code}: {response.text}"
124
+ )
125
+ logger.error(error_msg)
126
+ raise ClientTransportException(error_msg)
127
+
128
+ # Instantiate a SignedMessage object from the response bytes.
129
+ sealed_response = transport.parser.from_bytes(response.content)
130
+
131
+ # Use the static key that was provided when the client was
132
+ # initialized, or retrieve the key using DNS. The DNS
133
+ # implementation caches the DNS lookups so they are not that
134
+ # expensive after the first time.
135
+ if not (recipient_signing_key := self.recipient_signing_key):
136
+ recipient_signing_key = transport.get_key(self.recipient_domain, self.recipient_role)
137
+
138
+ # Unseal the response using the other party's public signing
139
+ # key and return the response message within it
140
+ return transport.unseal_message(
141
+ message=sealed_response.body,
142
+ public_key=recipient_signing_key
143
+ )
144
+
145
+
146
+ # ------------------------------------------------------------ #
147
+ # Methods related to queueing and scheduling outgoing #
148
+ # messages. #
149
+ # ------------------------------------------------------------ #
150
+
151
+ def _queue_message(self, message, callback, attempt=1):
152
+ self.outgoing_queue.put((message, callback, attempt))
153
+ self._run_outgoing_workers()
154
+
155
+ def _outgoing_worker(self):
156
+ while True:
157
+ message, callback, attempt = self.outgoing_queue.get()
158
+ try:
159
+ response = self._send_message(message)
160
+ except Exception as exc:
161
+ if attempt <= self.num_delivery_attempts:
162
+ # Reschedule with exponential backoff
163
+ delay_time = self.exponential_retry_factor * self.exponential_retry_base ** attempt
164
+ logger.warning(
165
+ f"Outgoing message {message.__class__.__name__} to "
166
+ f"{message.recipient_domain} could not be delivered "
167
+ f"due to a {exc.__class__.__name__}, will try again in {delay_time:.0f} seconds."
168
+ )
169
+ self.scheduler.enter(
170
+ delay=delay_time,
171
+ priority=1,
172
+ action=self._queue_message,
173
+ argument=((message, callback, attempt + 1))
174
+ )
175
+ self._run_scheduler()
176
+ else:
177
+ logger.error(
178
+ f"Could not deliver {message.__class__.__name__} "
179
+ f"to {self.recipient_role} at {self.recipient_domain}, "
180
+ f"even after {self.num_delivery_attempts} attempts."
181
+ )
182
+ else:
183
+ try:
184
+ callback(response)
185
+ except Exception as err:
186
+ logger.error("There was an exception during the callback "
187
+ f"for a {message.__class__.__name__} message: "
188
+ f"{err.__class__.__name__}: {err}")
189
+ finally:
190
+ self.outgoing_queue.task_done()
191
+
192
+ def _run_scheduler(self):
193
+ """
194
+ Make sure the scheduler thread is running and awake.
195
+ """
196
+ if not self.scheduler_thread:
197
+ self.scheduler_thread = Thread(target=self._scheduler_thread, daemon=True)
198
+ self.scheduler_thread.start()
199
+ self.scheduler_event.set()
200
+
201
+ def _scheduler_thread(self):
202
+ """
203
+ Intended to run the python scheduler in a background thread.
204
+ You can wake it up anytime by setting the scheduler event.
205
+ """
206
+ while True:
207
+ self.scheduler_event.wait()
208
+ self.scheduler_event.clear()
209
+ self.scheduler.run()
210
+
211
+ def _run_outgoing_workers(self):
212
+ """
213
+ Start up the outgoing queue workers.
214
+ """
215
+ if not self.outgoing_workers:
216
+ self.outgoing_workers = [Thread(target=self._outgoing_worker, daemon=True) for _ in range(self.num_outgoing_workers)]
217
+ for thread in self.outgoing_workers:
218
+ thread.start()
219
+
220
+ def __enter__(self):
221
+ return self
222
+
223
+ def __exit__(self, *args, **kwargs):
224
+ pass
@@ -0,0 +1,25 @@
1
+ from .base_client import ShapeshifterClient
2
+ from ..uftp import AgrPortfolioUpdateResponse, AgrPortfolioQueryResponse
3
+
4
+
5
+ class ShapeshifterCroAgrClient(ShapeshifterClient):
6
+ """
7
+ Client that allows the CRO to connect to the Aggregator.
8
+ """
9
+
10
+ sender_role = "CRO"
11
+ recipient_role = "AGR"
12
+
13
+ def send_agr_portfolio_update_response(self, message: AgrPortfolioUpdateResponse):
14
+ """
15
+ The DSOPortfolioUpdate is used by the DSO to indicate on which
16
+ congestion points it wants to engage in flexibility trading.
17
+ """
18
+ return self._send_message(message)
19
+
20
+ def send_agr_portfolio_query_response(self, message: AgrPortfolioQueryResponse):
21
+ """
22
+ DSOPortfolioQuery is used by DSOs to discover which AGRs represent
23
+ connections on its registered congestion point(s).
24
+ """
25
+ return self._send_message(message)