From 1c887736519330d4a32c36a7afc60fce364efcd3 Mon Sep 17 00:00:00 2001 From: Stan Janssen Date: Wed, 12 Feb 2025 15:53:44 +0100 Subject: [PATCH 1/5] Remove pre-processing and re-organize UFTP classes This removes the ability to manually determine the response to a Shapeshifter message, because there was no provision in the standardfor providing any useful information in the response. This also re-organizes some of the UFTP classes into smaller files. --- docs/source/concepts.rst | 72 +- docs/source/examples/index.rst | 25 - setup.py | 5 +- shapeshifter_uftp/service/agr_service.py | 195 +-- shapeshifter_uftp/service/base_service.py | 67 +- shapeshifter_uftp/service/cro_service.py | 64 +- shapeshifter_uftp/service/dso_service.py | 172 +-- shapeshifter_uftp/uftp/__init__.py | 284 ++-- shapeshifter_uftp/uftp/agr_cro.py | 331 +---- shapeshifter_uftp/uftp/agr_dso.py | 1175 ----------------- shapeshifter_uftp/uftp/common.py | 199 --- shapeshifter_uftp/uftp/cro_dso.py | 343 +---- shapeshifter_uftp/uftp/enums.py | 26 + shapeshifter_uftp/uftp/messages/__init__.py | 16 + .../uftp/messages/agr_portfolio_query.py | 231 ++++ .../uftp/messages/agr_portfolio_update.py | 101 ++ .../uftp/messages/d_prognosis.py | 122 ++ .../uftp/messages/dso_portfolio_query.py | 164 +++ .../uftp/messages/dso_portfolio_update.py | 178 +++ .../uftp/messages/flex_message.py | 61 + shapeshifter_uftp/uftp/messages/flex_offer.py | 194 +++ .../uftp/messages/flex_offer_revocation.py | 39 + shapeshifter_uftp/uftp/messages/flex_order.py | 174 +++ .../uftp/messages/flex_request.py | 137 ++ .../uftp/messages/flex_reservation_update.py | 94 ++ .../uftp/messages/flex_settlement.py | 449 +++++++ .../uftp/{ => messages}/metering.py | 7 +- .../uftp/messages/payload_message.py | 117 ++ .../uftp/messages/signed_message.py | 59 + .../uftp/messages/test_message.py | 20 + test/helpers/messages.py | 6 +- test/helpers/services.py | 119 +- test/test_client_errors.py | 24 +- test/test_client_with_workers.py | 35 +- test/test_communications.py | 108 +- test/test_default_responses.py | 12 +- test/test_presence_of_service_methods.py | 17 +- test/test_service_errors.py | 50 +- 38 files changed, 2545 insertions(+), 2947 deletions(-) create mode 100644 shapeshifter_uftp/uftp/enums.py create mode 100644 shapeshifter_uftp/uftp/messages/__init__.py create mode 100644 shapeshifter_uftp/uftp/messages/agr_portfolio_query.py create mode 100644 shapeshifter_uftp/uftp/messages/agr_portfolio_update.py create mode 100644 shapeshifter_uftp/uftp/messages/d_prognosis.py create mode 100644 shapeshifter_uftp/uftp/messages/dso_portfolio_query.py create mode 100644 shapeshifter_uftp/uftp/messages/dso_portfolio_update.py create mode 100644 shapeshifter_uftp/uftp/messages/flex_message.py create mode 100644 shapeshifter_uftp/uftp/messages/flex_offer.py create mode 100644 shapeshifter_uftp/uftp/messages/flex_offer_revocation.py create mode 100644 shapeshifter_uftp/uftp/messages/flex_order.py create mode 100644 shapeshifter_uftp/uftp/messages/flex_request.py create mode 100644 shapeshifter_uftp/uftp/messages/flex_reservation_update.py create mode 100644 shapeshifter_uftp/uftp/messages/flex_settlement.py rename shapeshifter_uftp/uftp/{ => messages}/metering.py (96%) create mode 100644 shapeshifter_uftp/uftp/messages/payload_message.py create mode 100644 shapeshifter_uftp/uftp/messages/signed_message.py create mode 100644 shapeshifter_uftp/uftp/messages/test_message.py diff --git a/docs/source/concepts.rst b/docs/source/concepts.rst index 32c5c47..deae347 100644 --- a/docs/source/concepts.rst +++ b/docs/source/concepts.rst @@ -6,26 +6,7 @@ Communication Structure This library provides all the parts you need to build a Shapeshifter-compliant participant. -Each request is a subclass of :code:`PayloadMessage`, and the receiving party responds using a :code:`PayloadMessageResponse`. The PayloadMessageResponse is merely an indication whether the message was accepted, technically. In shapeshifter-UFTP this is called the **pre-processing** of a request, and this step happens synchronously inside the HTTP Request context. A typical pre-processing step might look like this: - -.. code-block:: python3 - - from shapeshifter_uftp import ( - ShapeshifterAgrService, - AcceptedRejected, - FlexRequest, - PayloadMessageResponse - ) - - class MyAggregatorService(ShapeshifterAgrService): - - ... - - def pre_process_flex_request(self, message: FlexRequest) -> PayloadMessageResponse: - return PayloadMessagesResponse(result=AcceptedRejected.ACCEPTED) - - ... - +Each request is a subclass of :code:`PayloadMessage`. The python library performs some checks on the validity of the message, and responds with an appropriate HTTP status code. If the message was valid, it is then handed to one of your functions, so that you can send the response to this message. After receiving the message, the receiving party usually wants to send an actual response of some kind. For instance, a :code:`FlexRequest` message from the Distribution System Operator DSO might be replied to using a :code:`FlexOffer` message from the Aggregator (AGR). In shapeshifter-uftp this is called the **processing** step, and happens separately from the request context. A typical post-processing step might look like this: @@ -43,9 +24,6 @@ After receiving the message, the receiving party usually wants to send an actual ... - def pre_process_flex_request(self, message: FlexRequest) -> PayloadMessageResponse: - return PayloadMessagesResponse(result=AcceptedRejected.ACCEPTED) - def process_flex_request(self, message: FlexRequest): # Do some work to determine what flexibility we can offer to the DSO available_flex = my_backend.get_available_flexibility(...) @@ -62,42 +40,42 @@ This pattern repeats for all the messages that are exchanged between the partici **Aggregator (AGR) Service:** - Messages from the DSO: - - :code:`(pre_)process_d_prognosis_response` - - :code:`(pre_)process_flex_request` - - :code:`(pre_)process_flex_offer_response` - - :code:`(pre_)process_flex_offer_revocation_response` - - :code:`(pre_)process_flex_order` - - :code:`(pre_)process_flex_reservation_update` - - :code:`(pre_)process_flex_settlement` - - :code:`(pre_)process_metering_response` + - :code:`process_d_prognosis_response` + - :code:`process_flex_request` + - :code:`process_flex_offer_response` + - :code:`process_flex_offer_revocation_response` + - :code:`process_flex_order` + - :code:`process_flex_reservation_update` + - :code:`process_flex_settlement` + - :code:`process_metering_response` - Messages from the CRO: - - :code:`(pre_)process_agr_portfolio_query_response` - - :code:`(pre_)process_agr_portfolio_update_response` + - :code:`process_agr_portfolio_query_response` + - :code:`process_agr_portfolio_update_response` **Common Reference Operator (CRO) Service:** - Messages from the Aggregator - - :code:`(pre_)process_agr_portfolio_query` - - :code:`(pre_)process_agr_portfolio_update` + - :code:`process_agr_portfolio_query` + - :code:`process_agr_portfolio_update` - Messages from the DSO - - :code:`(pre_)process_dso_portfolio_query` - - :code:`(pre_)process_dso_portfolio_update` + - :code:`process_dso_portfolio_query` + - :code:`process_dso_portfolio_update` **Distribution System Operator (DSO) Service:** - Messages from the Aggregator: - - :code:`(pre_)process_d_prognosis` - - :code:`(pre_)process_flex_request_response` - - :code:`(pre_)process_flex_offer` - - :code:`(pre_)process_flex_order_response` - - :code:`(pre_)process_flex_offer_revocation` - - :code:`(pre_)process_flex_reservation_update_response` - - :code:`(pre_)process_flex_settlement_response` - - :code:`(pre_)process_metering` + - :code:`process_d_prognosis` + - :code:`process_flex_request_response` + - :code:`process_flex_offer` + - :code:`process_flex_order_response` + - :code:`process_flex_offer_revocation` + - :code:`process_flex_reservation_update_response` + - :code:`process_flex_settlement_response` + - :code:`process_metering` - Messages from the CRO: - - :code:`(pre_)process_dso_portfolio_query_response` - - :code:`(pre_)process_dso_portfolio_update_response` + - :code:`process_dso_portfolio_query_response` + - :code:`process_dso_portfolio_update_response` Identification of participants diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index a171962..474bc7a 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -146,28 +146,3 @@ Self-contained Aggregator service and client except: aggregator.stop() break - - -Pre-processing messages ------------------------ - -By default, Shapeshifter-UFTP will do basic message and schema validations on incoming messages, and send an ``ACCEPTED`` response back to the requesting participant as the initial HTTP response. Your ``process_*`` handler is then called separately so that you can do longer-running processing in the background and optionally send a new message to the participant. - -If you want to override the initial response, you can implement a `pre_process_*` method for the specific messages you want to pre-process. This method should then return a PayloadMessageRseponse object that contains thet status. If your method returns a PayloadMessageResponse with status REJECTED, the normal `process_*` method will not be called for that message. - -Example: - -.. code-block:: python3 - - class MyAggregatorService(ShapeshifterAggregatorService): - - ... - - def pre_process_flex_reservation_update(self, message: FlexReservationUpdate): - return PayloadMessageResponse( - result=REJECTED, - rejection_reason="Flex Reservation Updates are not supported" - ) - - ... - diff --git a/setup.py b/setup.py index e7101ea..fa0cc06 100644 --- a/setup.py +++ b/setup.py @@ -7,15 +7,16 @@ description="Allows connections between DSO, AGR and CRO using the Shapeshifter (UFTP) protocol.", packages=[ "shapeshifter_uftp", - "shapeshifter_uftp.uftp", "shapeshifter_uftp.client", "shapeshifter_uftp.service", + "shapeshifter_uftp.uftp", + "shapeshifter_uftp.uftp.messages" ], install_requires=[ "xsdata[lxml]>=24.4,<=24.7", "pynacl==1.5.0", "dnspython==2.6.1", - "fastapi>=0.110,<=0.113", + "fastapi>=0.110,<0.113", "fastapi-xml==1.1.0", "requests", "uvicorn", diff --git a/shapeshifter_uftp/service/agr_service.py b/shapeshifter_uftp/service/agr_service.py index df88720..5b5df18 100644 --- a/shapeshifter_uftp/service/agr_service.py +++ b/shapeshifter_uftp/service/agr_service.py @@ -41,14 +41,9 @@ class ShapeshifterAgrService( MeteringResponse, ] - # ------------------------------------------------------------ # - # Methods related to processing D Prognosis Response # - # messages # - # ------------------------------------------------------------ # - def pre_process_d_prognosis_response( - self, message: DPrognosisResponse # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + @abstractmethod + def process_d_prognosis_response(self, message: DPrognosisResponse): """ FlexOffer messages are used by AGRs to make DSOs an offer for provision of flexibility. A FlexOffer message contains a list of ISPs and, for @@ -61,39 +56,11 @@ def pre_process_d_prognosis_response( sure that it can actually provide the flexibility offered across all of its FlexOffers. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) - - @abstractmethod - def process_d_prognosis_response(self, message: DPrognosisResponse): - """ - This method is called after the pre_process_flex_offer method is - completed. It gives you the chance to perform longer-running operations - outside of the request context. - """ - - # ------------------------------------------------------------ # - # Methods related to processing Flex Request messages # - # ------------------------------------------------------------ # - def pre_process_flex_request( - self, message: FlexRequest # pylint: disable=unused-argument - ) -> PayloadMessageResponse: - """ - FlexRequest messages are used by DSOs to request flexibility from AGRs. - In addition to one or more ISP elements with Disposition=Requested, - indicating the actual need to reduce consumption or production, the - message should also include the remaining ISPs for the current period - where Disposition=Available. - """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod def process_flex_request(self, message: FlexRequest): """ - This function is called after the pre_process_flex_request is completed. - It gives you the chance to do longer-running computations or operations - on this message. - This method should probably end by sending some Flex Offers to the DSO:: with self.dso_client(message.sender_domain) as client: @@ -101,29 +68,10 @@ def process_flex_request(self, message: FlexRequest): # Do something with the response here. """ - # ------------------------------------------------------------ # - # Methods related to processing Flex Offer Response messages # - # ------------------------------------------------------------ # - - def pre_process_flex_offer_response( - self, message: FlexOfferResponse # pylint: disable=unused-argument - ) -> PayloadMessageResponse: - """ - FlexRequest messages are used by DSOs to request flexibility from AGRs. - In addition to one or more ISP elements with Disposition=Requested, - indicating the actual need to reduce consumption or production, the - message should also include the remaining ISPs for the current period - where Disposition=Available. - """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod def process_flex_offer_response(self, message: FlexOfferResponse): """ - This function is called after the pre_process_flex_request is completed. - It gives you the chance to do longer-running computations or operations - on this message. - This method should probably end by sending some Flex Offers to the DSO:: with self.dso_client(message.sender_domain) as client: @@ -131,30 +79,15 @@ def process_flex_offer_response(self, message: FlexOfferResponse): # Do something with the response here. """ - # ------------------------------------------------------------ # - # Methods related to processing Flex Offer Revocation # - # Response messages # - # ------------------------------------------------------------ # - - def pre_process_flex_offer_revocation_response( - self, message: FlexOfferRevocationResponse # pylint: disable=unused-argument - ) -> PayloadMessageResponse: - """ - Upon receiving and processing a FlexOfferRevocation message, the - receiving implementation must reply with a FlexOfferRevocationResponse, - indicating whether the revocation was handled successfully. - """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod def process_flex_offer_revocation_response( self, message: FlexOfferRevocationResponse ): """ - This method is called after the pre_process_flex_offer_revocation_response method is - completed. It gives you the chance to perform longer-running operations - outside of the request context. This method is not expected to return - anything. + Upon receiving and processing a FlexOfferRevocation message, the + receiving implementation must reply with a FlexOfferRevocationResponse, + indicating whether the revocation was handled successfully. It is advised that this method ends by sending a FlexSettlementResponse to the DSO:: @@ -163,13 +96,8 @@ def process_flex_offer_revocation_response( # do something with the response here. """ - # ------------------------------------------------------------ # - # Methods related to processing FlexOrder messages # - # ------------------------------------------------------------ # - - def pre_process_flex_order( - self, message: FlexOrder # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + @abstractmethod + def process_flex_order(self, message: FlexOrder): """ FlexOrder messages are used by DSOs to purchase flexibility from an AGR based on a previous FlexOffer. A FlexOrder message contains a list of @@ -180,24 +108,9 @@ def pre_process_flex_order( (and must) reject FlexOrder messages where the ISP list is not exactly the same as offered. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_flex_order(self, message: FlexOrder): - """ - This method is called after the pre_process_flex_order is completed. It - gives you the cance to perform longer-running operations outside of the - request context. This method is not expected to return anything. - """ - - # ------------------------------------------------------------ # - # Methods related to processing FlexReservationUpdate # - # messages # - # ------------------------------------------------------------ # - - def pre_process_flex_reservation_update( - self, message: FlexReservationUpdate # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_flex_reservation_update(self, message: FlexReservationUpdate): """ For bilateral contracts, FlexReservationUpdate messages are used by DSOs to signal to an AGR which part of the contracted volume is still @@ -206,39 +119,14 @@ def pre_process_flex_reservation_update( power is still reserved. Zero power means that no power is reserved for that ISP and the sign of the power indicates the direction. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_flex_reservation_update(self, message: FlexReservationUpdate): - """ - This method is called after the pre_process_flex_reservation_update - method is completed. It gives you the chance to perform longer-running - operations outside of the request context. This method is not expected - to return anything. - """ - - # ------------------------------------------------------------ # - # Methods related to processing Flex Settlement messages # - # ------------------------------------------------------------ # - - def pre_process_flex_settlement( - self, message: FlexSettlement # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_flex_settlement(self, message: FlexSettlement): """ The FlexSettlement message is sent by DSOs on a regular basis (typically monthly) to AGRs, in order to initiate settlement. It includes a list of all FlexOrders placed by the originating party during the settlement period. - """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) - - @abstractmethod - def process_flex_settlement(self, message: FlexSettlement): - """ - This method is called after the pre_process_flex_settlement method is - completed. It gives you the chance to perform longer-running operations - outside of the request context. This method is not expected to return - anything. It is advised that this method ends by sending a FlexSettlementResponse to the DSO:: @@ -247,43 +135,16 @@ def process_flex_settlement(self, message: FlexSettlement): # do something with the response here. """ - # ------------------------------------------------------------ # - # Methods related to processing Metering Response messages # - # ------------------------------------------------------------ # - - def pre_process_metering_response( - self, message: MeteringResponse # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + @abstractmethod + def process_metering_response(self, message: MeteringResponse): """ Upon receiving and processing a Metering message, the receiving implementation must reply with a MeteringResponse, indicating whether the update was handled successfully. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_metering_response(self, message: MeteringResponse): - """ - This method is called after the pre_process_metering_response method is - completed. It gives you the chance to perform longer-running operations - outside of the request context. This method is not expected to return - anything. - - It is advised that this method ends by sending a FlexSettlementResponse to the DSO:: - - with self.get_dso_client(message.sender_domain): - response = client.send_flex_settlement_response(FlexSettlementResponse(...)) - # do something with the response here. - """ - - # ------------------------------------------------------------ # - # Methods related to processing Portfolio Query Response # - # messages # - # ------------------------------------------------------------ # - - def pre_process_agr_portfolio_query_response( - self, message: AgrPortfolioQueryResponse # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_agr_portfolio_query_response(self, message: AgrPortfolioQueryResponse): """ The AgrPortfolioQueryResponse is sent by the CRO after you sent a AgrPortfolioQuery. It contains the list of your connections. It is @@ -291,15 +152,6 @@ def pre_process_agr_portfolio_query_response( this function, but return a PayloadMessageResponse quickly. Longer-running operations (like a database sync) should be done inside the process_agr_portfolio_query_response method. - """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) - - @abstractmethod - def process_agr_portfolio_query_response(self, message: AgrPortfolioQueryResponse): - """ - This method is called after the agr_portfolio_query_response method is - completed. It gives you the chance to perform longer-running operations - outside of the request context. If the list of connections does not match what you expected it to be, you can send an AgrPortfolioUpdate message at the end @@ -310,32 +162,13 @@ def process_agr_portfolio_query_response(self, message: AgrPortfolioQueryRespons # Do something with the response here """ - # ------------------------------------------------------------ # - # Methods related to processing Portfolio Update # - # Response messages # - # ------------------------------------------------------------ # - - def pre_process_agr_portfolio_update_response( - self, message: AgrPortfolioUpdateResponse # pylint: disable=unused-argument - ): - """ - The AgrPortfolioUptadeResponse is sent by the CRO after you sent a - AgrPortfolioUpdate. It is merely a status updateIt is - recommended that you do not perform any long-running operations inside - this function, but return a PayloadMessageResponse quickly. - Longer-running operations (like a database sync) should be done inside - the process_agr_portfolio_query_response method. - """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) - @abstractmethod def process_agr_portfolio_update_response( self, message: AgrPortfolioUpdateResponse ): """ - This method is called after the agr_portfolio_update_response method is - completed. It gives you the chance to perform longer-running operations - outside of the request context. + The AgrPortfolioUptadeResponse is sent by the CRO after you sent a + AgrPortfolioUpdate. """ # ------------------------------------------------------------ # diff --git a/shapeshifter_uftp/service/base_service.py b/shapeshifter_uftp/service/base_service.py index 30c3d01..c0dd2ff 100644 --- a/shapeshifter_uftp/service/base_service.py +++ b/shapeshifter_uftp/service/base_service.py @@ -6,7 +6,7 @@ from uuid import uuid4 import uvicorn -from fastapi import FastAPI +from fastapi import FastAPI, Response from fastapi.exceptions import HTTPException from fastapi_xml import XmlAppResponse, XmlRoute @@ -24,6 +24,7 @@ PayloadMessage, PayloadMessageResponse, SignedMessage, + request_response_map, ) @@ -92,7 +93,7 @@ def __init__( self.app.router.add_api_route( path, endpoint=self._receive_message, - response_model=SignedMessage, + response_model=None, methods=["POST"], status_code=200, ) @@ -105,6 +106,7 @@ def __init__( # Create an inbound executor worker self.inbound_executor = ThreadPoolExecutor(max_workers=self.num_inbound_threads) + self.outbound_executor = ThreadPoolExecutor(max_workers=self.num_outbound_threads) def run(self): @@ -139,7 +141,7 @@ def stop(self): # Shapeshifter UFTP implementation. # # ------------------------------------------------------------ # - def _receive_message(self, message: SignedMessage) -> SignedMessage: + def _receive_message(self, message: SignedMessage) -> None: """ The default entrypoint for the route. This will unpack the message and validate the signature. It will thes pass the @@ -180,46 +182,14 @@ def _receive_message(self, message: SignedMessage) -> SignedMessage: raise HTTPException(err.http_status_code) from err except FunctionalException as err: - response = PayloadMessageResponse( - result = AcceptedRejected.REJECTED, - rejection_reason = err.rejection_reason - ) + self.outbound_executor.submit(self._reject_message, message, unsealed_message, err.rejection_reason) else: # If the initial checks passed, process the message in the # user-defined pipeline. - response = self._pre_process_message(unsealed_message) - if response.result == AcceptedRejected.ACCEPTED: - self.inbound_executor.submit(self._process_message, unsealed_message) - - # Add the default parameters to the PayloadMessageResponse. - response.version = self.protocol_version - response.sender_domain = self.sender_domain - response.recipient_domain = message.sender_domain - response.time_stamp = response.time_stamp or datetime.now(timezone.utc).isoformat() - response.message_id = response.message_id or str(uuid4()) - response.conversation_id = unsealed_message.conversation_id - response.reference_message_id = unsealed_message.message_id - - # Pack up the sealed message blob inside a SignedMessage - # envelope. We attach our sender domain and role so that the - # other side can look up the relevent keys for opening the - # message. - sealed_message = transport.seal_message(response, self.signing_key) - return SignedMessage( - sender_domain = self.sender_domain, - sender_role = self.sender_role, - body = sealed_message, - ) + self.inbound_executor.submit(self._process_message, unsealed_message) - def _pre_process_message(self, message: PayloadMessage) -> PayloadMessageResponse: - """ - Find the relevant pre-processing method to handle the HTTP - request for the given message, and return its result. - """ - pre_process_method_name = f"pre_process_{snake_case(message.__class__.__name__)}" - pre_process_method = getattr(self, pre_process_method_name) - return pre_process_method(message) + return Response(status_code=200) def _process_message(self, message: PayloadMessage): """ @@ -252,6 +222,27 @@ def _get_client(self, recipient_domain, recipient_role): recipient_signing_key = recipient_signing_key ) + def _reject_message(self, message, unsealed_message, reason): + """ + Send a rejection to the sending party. + """ + if type(unsealed_message) not in request_response_map: + return + + client = self._get_client(message.sender_domain, message.sender_role) + response_type = request_response_map[type(unsealed_message)] + response_id_field = snake_case(type(unsealed_message).__name__) + "_message_id" + message_contents = { + "recipient_domain": message.sender_domain, + "conversation_id": unsealed_message.conversation_id, + "result": AcceptedRejected.REJECTED, + "rejection_reason": reason, + response_id_field: unsealed_message.message_id + } + response_message = response_type(**message_contents) + client._send_message(response_message) + + def __enter__(self): """ Context-manager method that allows an instance of this class to be diff --git a/shapeshifter_uftp/service/cro_service.py b/shapeshifter_uftp/service/cro_service.py index 9bf7b5b..ba889d6 100644 --- a/shapeshifter_uftp/service/cro_service.py +++ b/shapeshifter_uftp/service/cro_service.py @@ -31,63 +31,25 @@ class ShapeshifterCroService(ShapeshifterService, ABC): # Methods related to Agr Portfolio Query messages # # ------------------------------------------------------------ # - def pre_process_agr_portfolio_query( - self, message: AgrPortfolioQuery # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + @abstractmethod + def process_agr_portfolio_query(self, message: AgrPortfolioQuery): """ The AGRPortfolioQuery is used by the AGR to retrieve additional information on the connections. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_agr_portfolio_query(self, message: AgrPortfolioQuery): - """ - This method is called after the agr_portfolio_query method is - completed. It gives you the chance to perform longer-running - operations outside of the request context. - """ - - # ------------------------------------------------------------ # - # Methods related to Agr Portfolio Update messages # - # ------------------------------------------------------------ # - - def pre_process_agr_portfolio_update( - self, message: AgrPortfolioUpdate # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_agr_portfolio_update(self, message: AgrPortfolioUpdate): """ The AGRPortfolioUpdate is used by the AGR to indicate on which Connections it represents prosumers. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_agr_portfolio_update(self, message: AgrPortfolioUpdate): - """ - This method is called after the agr_portfolio_update method is - completed. It gives you the chance to perform longer-running - operations outside of the request context. - """ - - # ------------------------------------------------------------ # - # Methods related to DSO Portfolie Query messages # - # ------------------------------------------------------------ # - - def pre_process_dso_portfolio_query( - self, message: DsoPortfolioQuery # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_dso_portfolio_query(self, message: DsoPortfolioQuery): """ DSOPortfolioQuery is used by DSOs to discover which AGRs represent connections on its registered congestion point(s). - """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) - - @abstractmethod - def process_dso_portfolio_query(self, message: DsoPortfolioQuery): - """ - This method is called after the dso_portfolio_query method is - completed. It gives you the chance to perform longer-running - operations outside of the request context. You should end this method by sending a DsoPortfolioQueryResponse back to the DSO:: @@ -96,25 +58,11 @@ def process_dso_portfolio_query(self, message: DsoPortfolioQuery): client.send_portfolio_query_response """ - # ------------------------------------------------------------ # - # Methods related to DSO Portfolio Query messages # - # ------------------------------------------------------------ # - - def pre_process_dso_portfolio_update( - self, message: DsoPortfolioUpdate # pylint: disable=unused-argument - ) -> PayloadMessageResponse: - """ - The DSOPortfolioUpdate is used by the DSO to indicate on which - congestion points it wants to engage in flexibility trading. - """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) - @abstractmethod def process_dso_portfolio_update(self, message: DsoPortfolioUpdate): """ - This method is called after the dso_portfolio_update method is - completed. It gives you the chance to perform longer-running - operations outside of the request context. + The DSOPortfolioUpdate is used by the DSO to indicate on which + congestion points it wants to engage in flexibility trading. """ # ------------------------------------------------------------ # diff --git a/shapeshifter_uftp/service/dso_service.py b/shapeshifter_uftp/service/dso_service.py index 7d0647a..fddfcf7 100644 --- a/shapeshifter_uftp/service/dso_service.py +++ b/shapeshifter_uftp/service/dso_service.py @@ -42,13 +42,8 @@ class ShapeshifterDsoService(ShapeshifterService, ABC): Metering, ] - # ------------------------------------------------------------ # - # Methods related to processing D Prognosis messages # - # ------------------------------------------------------------ # - - def pre_process_d_prognosis( - self, message: DPrognosis # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + @abstractmethod + def process_d_prognosis(self, message: DPrognosis): """ D-Prognosis messages are used to communicate D-prognoses between AGRs and DSOs. D-Prognosis messages always contain data for all ISPs for the @@ -57,24 +52,9 @@ def pre_process_d_prognosis( settlement phase. Receiving implementations should ignore the information supplied for those ISPs. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_d_prognosis(self, message: DPrognosis): - """ - This method is called after the pre_process_d_prognosis method - is completed. It gives you the chance to perform longer-running - operations outside of the request context. - """ - - # ------------------------------------------------------------ # - # Methods related to processing Flex Request Response # - # messages # - # ------------------------------------------------------------ # - - def pre_process_flex_request_response( - self, message: FlexRequestResponse # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_flex_request_response(self, message: FlexRequestResponse): """ FlexOffer messages are used by AGRs to make DSOs an offer for provision of flexibility. A FlexOffer message contains a list of ISPs and, for @@ -87,23 +67,9 @@ def pre_process_flex_request_response( sure that it can actually provide the flexibility offered across all of its FlexOffers. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_flex_request_response(self, message: FlexRequestResponse): - """ - This method is called after the pre_process_flex_offer method is - completed. It gives you the chance to perform longer-running operations - outside of the request context. - """ - - # ------------------------------------------------------------ # - # Methods related to processing Flex Offer messages # - # ------------------------------------------------------------ # - - def pre_process_flex_offer( - self, message: FlexOffer # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_flex_offer(self, message: FlexOffer): """ FlexOffer messages are used by AGRs to make DSOs an offer for provision of flexibility. A FlexOffer message contains a list of ISPs and, for @@ -116,96 +82,37 @@ def pre_process_flex_offer( sure that it can actually provide the flexibility offered across all of its FlexOffers. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_flex_offer(self, message: FlexOffer): - """ - This method is called after the pre_process_flex_offer method is - completed. It gives you the chance to perform longer-running operations - outside of the request context. - """ - - # ------------------------------------------------------------ # - # Methods related to processing Flex Order Response messages # - # ------------------------------------------------------------ # - - def pre_process_flex_order_response( - self, message: FlexOrderResponse # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_flex_order_response(self, message: FlexOrderResponse): """ Upon receiving and processing a FlexOrder message, the receiving implementation must reply with a FlexOrderResponse, indicating whether the update was handled successfully. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_flex_order_response(self, message: FlexOrderResponse): - """ - This method is called after the pre_process_flex_order_response method - is completed. It gives you the chance to perform longer-running - operations outside of the request context. - """ - - # ------------------------------------------------------------ # - # Methods related to processing Flex Offer Revocation # - # messages # - # ------------------------------------------------------------ # - - def pre_process_flex_offer_revocation( - self, message: FlexOfferRevocation # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_flex_offer_revocation(self, message: FlexOfferRevocation): """ The FlexOfferRevocation message is used by the AGR to revoke a FlexOffer previously sent to a DSO. It voids the FlexOffer, even if its validity time has not yet expired. Revocation is not allowed for FlexOffers that already have associated accepted FlexOrders. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_flex_offer_revocation(self, message: FlexOfferRevocation): - """ - This method runs separately from the pre_process_flex_offer_revocation - function. It gives you the chance to perform longer-running operations - outside of the request context. - """ - - # ------------------------------------------------------------ # - # Methods related to processing Flex Reservation Update # - # Response messages # - # ------------------------------------------------------------ # - - def pre_process_flex_reservation_update_response( - self, message: FlexReservationUpdateResponse # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_flex_reservation_update_response( + self, message: FlexReservationUpdateResponse + ): """ The FlexOfferRevocation message is used by the AGR to revoke a FlexOffer previously sent to a DSO. It voids the FlexOffer, even if its validity time has not yet expired. Revocation is not allowed for FlexOffers that already have associated accepted FlexOrders. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_flex_reservation_update_response( - self, message: FlexReservationUpdateResponse - ): - """ - This method runs separately from the pre_process_flex_offer_revocation - function. It gives you the chance to perform longer-running operations - outside of the request context. - """ - - # ------------------------------------------------------------ # - # Methods related to processing Flex Settlement Response # - # messages # - # ------------------------------------------------------------ # - - def pre_process_flex_settlement_response( - self, message: FlexSettlementResponse # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_flex_settlement_response(self, message: FlexSettlementResponse): """ Upon receiving and processing a FlexSettlement message, the AGR must reply with a FlexSettlementResponse, indicating whether the initial @@ -213,70 +120,28 @@ def pre_process_flex_settlement_response( rejected, the DSO should consider all FlexOrderSettlement elements of that message related to potential dispute. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_flex_settlement_response(self, message: FlexSettlementResponse): - """ - This method runs separately from the - pre_process_flex_settlement_response function. It gives you the chance - to perform longer-running operations outside of the request context. - """ - - # ------------------------------------------------------------ # - # Methods related to processing DSO Portfolio Query Response # - # messages # - # ------------------------------------------------------------ # - - def pre_process_dso_portfolio_query_response( - self, message: DsoPortfolioQueryResponse # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_dso_portfolio_query_response(self, message: DsoPortfolioQueryResponse): """ Upon receiving and processing a DSOPortfolioQuery message, the receiving implementation must reply with a DSOPortfolioQueryResponse, indicating whether the query executed successfully, and if it did, including the query results. Most queries will return zero or more congestion points """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_dso_portfolio_query_response(self, message: DsoPortfolioQueryResponse): - """ - This method runs after the pre_process_dso_portfolio_query_response has - finised. - """ - - # ------------------------------------------------------------ # - # Methods related to processing DSO Portfolio Update # - # Response messages # - # ------------------------------------------------------------ # - - def pre_process_dso_portfolio_update_response( - self, message: DsoPortfolioUpdateResponse # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_dso_portfolio_update_response( + self, message: DsoPortfolioUpdateResponse + ): """ Upon receiving and processing a DSOPortfolioUpdate message, the receiving implementation must reply with a DSOPortfolioUpdateResponse, indicating whether the update was handled successfully. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) @abstractmethod - def process_dso_portfolio_update_response( - self, message: DsoPortfolioUpdateResponse - ): - """ - This method runs after the pre_process_portfolio_update_response method - has finished. - """ - - # ------------------------------------------------------------ # - # Methods related to processing Metering messages # - # ------------------------------------------------------------ # - - def pre_process_metering( - self, message: Metering # pylint: disable=unused-argument - ) -> PayloadMessageResponse: + def process_metering(self, message: Metering): """ The Metering message is an optional message. The DSO will specify whether metering messages are required for a given program. If metering @@ -286,13 +151,6 @@ def pre_process_metering( send the metering messages daily, once the metering data has been collected for the day. """ - return PayloadMessageResponse(result=AcceptedRejected.ACCEPTED) - - @abstractmethod - def process_metering(self, message: Metering): - """ - This method runs after the pre_process_metering method has finished. - """ # ------------------------------------------------------------ # # Convenience methods for getting a client to the designated # diff --git a/shapeshifter_uftp/uftp/__init__.py b/shapeshifter_uftp/uftp/__init__.py index 4e0a49f..90d7709 100644 --- a/shapeshifter_uftp/uftp/__init__.py +++ b/shapeshifter_uftp/uftp/__init__.py @@ -1,169 +1,115 @@ -from .agr_cro import ( - AgrPortfolioQuery, - AgrPortfolioQueryResponse, - AgrPortfolioQueryResponseCongestionPoint, - AgrPortfolioQueryResponseConnection, - AgrPortfolioQueryResponseDSOPortfolio, - AgrPortfolioQueryResponseDSOView, - AgrPortfolioUpdate, - AgrPortfolioUpdateConnection, - AgrPortfolioUpdateResponse, -) -from .agr_dso import ( - ContractSettlement, - ContractSettlementISP, - ContractSettlementPeriod, - DPrognosis, - DPrognosisISP, - DPrognosisResponse, - FlexMessage, - FlexOffer, - FlexOfferOption, - FlexOfferOptionISP, - FlexOfferResponse, - FlexOfferRevocation, - FlexOfferRevocationResponse, - FlexOrder, - FlexOrderISP, - FlexOrderResponse, - FlexOrderSettlement, - FlexOrderSettlementISP, - FlexOrderSettlementStatus, - FlexOrderStatus, - FlexRequest, - FlexRequestISP, - FlexRequestResponse, - FlexReservationUpdate, - FlexReservationUpdateISP, - FlexReservationUpdateResponse, - FlexSettlement, - FlexSettlementResponse, -) -from .common import ( - AcceptedDisputed, - AcceptedRejected, - AvailableRequested, - PayloadMessage, - PayloadMessageResponse, - RedispatchBy, - SignedMessage, - TestMessage, - TestMessageResponse, - UsefRole, -) -from .cro_dso import ( - DsoPortfolioQuery, - DsoPortfolioQueryCongestionPoint, - DsoPortfolioQueryConnection, - DsoPortfolioQueryResponse, - DsoPortfolioUpdate, - DsoPortfolioUpdateCongestionPoint, - DsoPortfolioUpdateConnection, - DsoPortfolioUpdateResponse, -) -from .metering import ( - Metering, - MeteringISP, - MeteringProfile, - MeteringProfileEnum, - MeteringResponse, - MeteringUnit, -) - -ACCEPTED = AcceptedRejected.ACCEPTED -REJECTED = AcceptedRejected.REJECTED - -__all__ = [ - "AcceptedRejected", - "AcceptedDisputed", - "AvailableRequested", - "AgrPortfolioQuery", - "AgrPortfolioQueryResponse", - "AgrPortfolioQueryResponseCongestionPoint", - "AgrPortfolioQueryResponseConnection", - "AgrPortfolioQueryResponseDSOPortfolio", - "AgrPortfolioQueryResponseDSOView", - "AgrPortfolioUpdate", - "AgrPortfolioUpdateConnection", - "AgrPortfolioUpdateResponse", - "ContractSettlement", - "ContractSettlementISP", - "ContractSettlementPeriod", - "DPrognosis", - "DPrognosisISP", - "DPrognosisResponse", - "DsoPortfolioQuery", - "DsoPortfolioQueryCongestionPoint", - "DsoPortfolioQueryConnection", - "DsoPortfolioQueryResponse", - "DsoPortfolioUpdate", - "DsoPortfolioUpdateCongestionPoint", - "DsoPortfolioUpdateConnection", - "DsoPortfolioUpdateResponse", - "FlexMessage", - "FlexOffer", - "FlexOfferOption", - "FlexOfferOptionISP", - "FlexOfferResponse", - "FlexOfferRevocation", - "FlexOfferRevocationResponse", - "FlexOrder", - "FlexOrderISP", - "FlexOrderResponse", - "FlexOrderSettlement", - "FlexOrderSettlementISP", - "FlexOrderSettlementStatus", - "FlexOrderStatus", - "FlexRequest", - "FlexRequestISP", - "FlexRequestResponse", - "FlexReservationUpdate", - "FlexReservationUpdateISP", - "FlexReservationUpdateResponse", - "FlexSettlement", - "FlexSettlementResponse", - "Metering", - "MeteringISP", - "MeteringProfile", - "MeteringProfileEnum", - "MeteringResponse", - "MeteringUnit", - "PayloadMessage", - "PayloadMessageResponse", - "SignedMessage", - "TestMessage", - "TestMessageResponse", - "UsefRole", - "RedispatchBy", -] - -routing_map = { - AgrPortfolioQuery: ("AGR", "CRO"), - AgrPortfolioQueryResponse: ("CRO", "AGR"), - AgrPortfolioUpdate: ("AGR", "CRO"), - AgrPortfolioUpdateResponse: ("CRO", "AGR"), - DPrognosis: ("AGR", "DSO"), - DPrognosisResponse: ("DSO", "AGR"), - DsoPortfolioQuery: ("DSO", "CRO"), - DsoPortfolioQueryResponse: ("CRO", "DSO"), - DsoPortfolioUpdate: ("DSO", "CRO"), - DsoPortfolioUpdateResponse: ("CRO", "DSO"), - FlexOffer: ("AGR", "DSO"), - FlexOfferResponse: ("DSO", "AGR"), - FlexOfferRevocation: ("AGR", "DSO"), - FlexOfferRevocationResponse: ("DSO", "AGR"), - FlexOrder: ("DSO", "AGR"), - FlexOrderResponse: ("AGR", "DSO"), - FlexRequest: ("DSO", "AGR"), - FlexRequestResponse: ("AGR", "DSO"), - FlexReservationUpdate: ("DSO", "AGR"), - FlexReservationUpdateResponse: ("AGR", "DSO"), - FlexSettlement: ("DSO", "AGR"), - FlexSettlementResponse: ("AGR", "DSO"), - Metering: ("AGR", "DSO"), - MeteringResponse: ("DSO", "AGR"), - -} - -origin_map = {key: origin for key, (origin, destination) in routing_map.items()} -destination_map = {key: destination for key, (origin, destination) in routing_map.items()} +from .enums import * +from .messages import * + +ACCEPTED = AcceptedRejected.ACCEPTED +REJECTED = AcceptedRejected.REJECTED + +__all__ = [ + "AcceptedRejected", + "AcceptedDisputed", + "AvailableRequested", + "AgrPortfolioQuery", + "AgrPortfolioQueryResponse", + "AgrPortfolioQueryResponseCongestionPoint", + "AgrPortfolioQueryResponseConnection", + "AgrPortfolioQueryResponseDSOPortfolio", + "AgrPortfolioQueryResponseDSOView", + "AgrPortfolioUpdate", + "AgrPortfolioUpdateConnection", + "AgrPortfolioUpdateResponse", + "ContractSettlement", + "ContractSettlementISP", + "ContractSettlementPeriod", + "DPrognosis", + "DPrognosisISP", + "DPrognosisResponse", + "DsoPortfolioQuery", + "DsoPortfolioQueryCongestionPoint", + "DsoPortfolioQueryConnection", + "DsoPortfolioQueryResponse", + "DsoPortfolioUpdate", + "DsoPortfolioUpdateCongestionPoint", + "DsoPortfolioUpdateConnection", + "DsoPortfolioUpdateResponse", + "FlexMessage", + "FlexOffer", + "FlexOfferOption", + "FlexOfferOptionISP", + "FlexOfferResponse", + "FlexOfferRevocation", + "FlexOfferRevocationResponse", + "FlexOrder", + "FlexOrderISP", + "FlexOrderResponse", + "FlexOrderSettlement", + "FlexOrderSettlementISP", + "FlexOrderSettlementStatus", + "FlexOrderStatus", + "FlexRequest", + "FlexRequestISP", + "FlexRequestResponse", + "FlexReservationUpdate", + "FlexReservationUpdateISP", + "FlexReservationUpdateResponse", + "FlexSettlement", + "FlexSettlementResponse", + "Metering", + "MeteringISP", + "MeteringProfile", + "MeteringProfileEnum", + "MeteringResponse", + "MeteringUnit", + "PayloadMessage", + "PayloadMessageResponse", + "SignedMessage", + "TestMessage", + "TestMessageResponse", + "UsefRole", + "RedispatchBy", +] + +routing_map = { + AgrPortfolioQuery: ("AGR", "CRO"), + AgrPortfolioQueryResponse: ("CRO", "AGR"), + AgrPortfolioUpdate: ("AGR", "CRO"), + AgrPortfolioUpdateResponse: ("CRO", "AGR"), + DPrognosis: ("AGR", "DSO"), + DPrognosisResponse: ("DSO", "AGR"), + DsoPortfolioQuery: ("DSO", "CRO"), + DsoPortfolioQueryResponse: ("CRO", "DSO"), + DsoPortfolioUpdate: ("DSO", "CRO"), + DsoPortfolioUpdateResponse: ("CRO", "DSO"), + FlexOffer: ("AGR", "DSO"), + FlexOfferResponse: ("DSO", "AGR"), + FlexOfferRevocation: ("AGR", "DSO"), + FlexOfferRevocationResponse: ("DSO", "AGR"), + FlexOrder: ("DSO", "AGR"), + FlexOrderResponse: ("AGR", "DSO"), + FlexRequest: ("DSO", "AGR"), + FlexRequestResponse: ("AGR", "DSO"), + FlexReservationUpdate: ("DSO", "AGR"), + FlexReservationUpdateResponse: ("AGR", "DSO"), + FlexSettlement: ("DSO", "AGR"), + FlexSettlementResponse: ("AGR", "DSO"), + Metering: ("AGR", "DSO"), + MeteringResponse: ("DSO", "AGR"), +} + +request_response_map = { + AgrPortfolioQuery: AgrPortfolioQueryResponse, + AgrPortfolioUpdate: AgrPortfolioUpdateResponse, + DPrognosis: DPrognosisResponse, + DsoPortfolioQuery: DsoPortfolioQueryResponse, + DsoPortfolioUpdate: DsoPortfolioUpdateResponse, + FlexOffer: FlexOfferResponse, + FlexOfferRevocation: FlexOfferRevocationResponse, + FlexOrder: FlexOrderResponse, + FlexRequest: FlexRequestResponse, + FlexReservationUpdate: FlexReservationUpdateResponse, + FlexSettlement: FlexSettlementResponse, + Metering: MeteringResponse, + TestMessage: TestMessageResponse, +} + +origin_map = {key: origin for key, (origin, destination) in routing_map.items()} +destination_map = {key: destination for key, (origin, destination) in routing_map.items()} diff --git a/shapeshifter_uftp/uftp/agr_cro.py b/shapeshifter_uftp/uftp/agr_cro.py index 0b87b19..30adcd7 100644 --- a/shapeshifter_uftp/uftp/agr_cro.py +++ b/shapeshifter_uftp/uftp/agr_cro.py @@ -1,317 +1,14 @@ -from dataclasses import dataclass, field -from typing import List, Optional - -from xsdata.models.datatype import XmlDate - -from .common import PayloadMessage, PayloadMessageResponse, RedispatchBy -from .defaults import DEFAULT_TIME_ZONE -from .validations import validate_list - -# pylint: disable=missing-class-docstring,duplicate-code - - - -@dataclass(kw_only=True) -class AgrPortfolioQueryResponseConnection: - """ - :ivar entity_address: EntityAddress of the Connection. - """ - class Meta: - name = "AGRPortfolioQueryResponseConnection" - - entity_address: str = field( - metadata={ - "name": "EntityAddress", - "type": "Attribute", - "required": True, - "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", - } - ) - - -@dataclass(kw_only=True) -class AgrPortfolioUpdateConnection: - """ - A connection that the AGR want the CRO to update. - - :ivar entity_address: EntityAddress of the Connection entity being - updated. - :ivar start_period: The first Period hat the AGR represents the - prosumer at this Connection. - :ivar end_period: The last Period that the AGR represents the - prosumer at this Connection, if applicable. - """ - class Meta: - name = "AGRPortfolioUpdateConnection" - - entity_address: str = field( - metadata={ - "name": "EntityAddress", - "type": "Attribute", - "required": True, - "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", - } - ) - start_period: XmlDate = field( - metadata={ - "name": "StartPeriod", - "type": "Attribute", - "required": True, - } - ) - end_period: Optional[XmlDate] = field( - default=None, - metadata={ - "name": "EndPeriod", - "type": "Attribute", - } - ) - - -@dataclass(kw_only=True) -class AgrPortfolioQueryResponseCongestionPoint: - """ - :ivar connection: - :ivar entity_address: EntityAddress of the CongestionPoint. - :ivar mutex_offers_supported: Indicates whether the DSO accepts - mutual exclusive FlexOffers on this CongestionPoint. - :ivar day_ahead_redispatch_by: Indicates which party is responsible - for day-ahead redispatch. - :ivar intraday_redispatch_by: Indicates which party is responsible - for intraday ahead redispatch, AGR or DSO. If not specified, - there will be no intraday trading on this CongestionPoint. - """ - class Meta: - name = "AGRPortfolioQueryResponseCongestionPoint" - - connections: List[AgrPortfolioQueryResponseConnection] = field( - default_factory=list, - metadata={ - "name": "Connection", - "type": "Element", - "min_occurs": 1, - } - ) - entity_address: str = field( - metadata={ - "name": "EntityAddress", - "type": "Attribute", - "required": True, - "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", - } - ) - mutex_offers_supported: bool = field( - metadata={ - "name": "MutexOffersSupported", - "type": "Attribute", - "required": True, - } - ) - day_ahead_redispatch_by: RedispatchBy = field( - metadata={ - "name": "DayAheadRedispatchBy", - "type": "Attribute", - "required": True, - } - ) - intraday_redispatch_by: Optional[RedispatchBy] = field( - default=None, - metadata={ - "name": "IntradayRedispatchBy", - "type": "Attribute", - } - ) - - def __post_init__(self): - validate_list('connections', self.connections, AgrPortfolioQueryResponseConnection, 1) - - -@dataclass(kw_only=True) -class AgrPortfolioQuery(PayloadMessage): - """ - :ivar time_zone: Time zone ID (as per the IANA time zone database, - http://www.iana.org/time-zones, for example: Europe/Amsterdam) - indicating the UTC offset that applies to the Period referenced - in this message. Although the time zone is a market-wide fixed - value, making this assumption explicit in each message is - important for validation purposes, allowing implementations to - reject messages with an errant UTC offset. - :ivar period: The Period for which the AGR requests the portfolio - information. - """ - class Meta: - name = "AGRPortfolioQuery" - - time_zone: str = field( - default=DEFAULT_TIME_ZONE, - metadata={ - "name": "TimeZone", - "type": "Attribute", - "required": True, - "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", - } - ) - period: XmlDate = field( - metadata={ - "name": "Period", - "type": "Attribute", - "required": True, - } - ) - - -@dataclass(kw_only=True) -class AgrPortfolioUpdateResponse(PayloadMessageResponse): - class Meta: - name = "AGRPortfolioUpdateResponse" - - agr_portfolio_update_message_id: str = field( - metadata={ - "name": "AGRPortfolioUpdateMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - -@dataclass(kw_only=True) -class AgrPortfolioUpdate(PayloadMessage): - """ - :ivar connection: - :ivar time_zone: Time zone ID (as per the IANA time zone database, - http://www.iana.org/time-zones, for example: Europe/Amsterdam) - indicating the UTC offset that applies to the Period referenced - in this message. Although the time zone is a market-wide fixed - value, making this assumption explicit in each message is - important for validation purposes, allowing implementations to - reject messages with an errant UTC offset. - """ - class Meta: - name = "AGRPortfolioUpdate" - - connections: List[AgrPortfolioUpdateConnection] = field( - default_factory=list, - metadata={ - "name": "Connection", - "type": "Element", - "min_occurs": 1, - } - ) - time_zone: str = field( - default=DEFAULT_TIME_ZONE, - metadata={ - "name": "TimeZone", - "type": "Attribute", - "required": True, - "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", - } - ) - - def __post_init__(self): - validate_list('connections', self.connections, AgrPortfolioUpdateConnection, 1) - - -@dataclass(kw_only=True) -class AgrPortfolioQueryResponseDSOPortfolio: - class Meta: - name = "AGRPortfolioQueryResponseDSOPortfolio" - - congestion_points: List[AgrPortfolioQueryResponseCongestionPoint] = field( - default_factory=list, - metadata={ - "name": "CongestionPoint", - "type": "Element", - "min_occurs": 1, - } - ) - dso_domain: str = field( - metadata={ - "name": "DSO-Domain", - "type": "Attribute", - "required": True, - "pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}", - } - ) - - def __post_init__(self): - validate_list('congestion_points', self.congestion_points, AgrPortfolioQueryResponseCongestionPoint, 1) - - -@dataclass(kw_only=True) -class AgrPortfolioQueryResponseDSOView: - class Meta: - name = "AGRPortfolioQueryResponseDSOView" - - dso_portfolios: List[AgrPortfolioQueryResponseDSOPortfolio] = field( - default_factory=list, - metadata={ - "name": "DSO-Portfolio", - "type": "Element", - "min_occurs": 1, - } - ) - connections: List[AgrPortfolioQueryResponseConnection] = field( - default_factory=list, - metadata={ - "name": "Connection", - "type": "Element", - } - ) - - def __post_init__(self): - validate_list('dso_portfolios', self.dso_portfolios, AgrPortfolioQueryResponseDSOPortfolio, 1) - - -@dataclass(kw_only=True) -class AgrPortfolioQueryResponse(PayloadMessageResponse): - """ - :ivar dso_view: - :ivar time_zone: Time zone ID (as per the IANA time zone database, - http://www.iana.org/time-zones, for example: Europe/Amsterdam) - indicating the UTC offset that applies to the Period referenced - in this message. Although the time zone is a market-wide fixed - value, making this assumption explicit in each message is - important for validation purposes, allowing implementations to - reject messages with an errant UTC offset. - :ivar period: The Period that the portfolio is valid. - """ - class Meta: - name = "AGRPortfolioQueryResponse" - - agr_portfolio_query_message_id: str = field( - metadata={ - "name": "AGRPortfolioQueryMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - dso_views: List[AgrPortfolioQueryResponseDSOView] = field( - default_factory=list, - metadata={ - "name": "DSO-View", - "type": "Element", - "min_occurs": 1, - } - ) - time_zone: str = field( - default=DEFAULT_TIME_ZONE, - metadata={ - "name": "TimeZone", - "type": "Attribute", - "required": True, - "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", - } - ) - period: XmlDate = field( - metadata={ - "name": "Period", - "type": "Attribute", - "required": True, - } - ) - - def __post_init__(self): - self.dso_views = validate_list('dso_views', self.dso_views, AgrPortfolioQueryResponseDSOView, 1) +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate + +from .common import PayloadMessage, PayloadMessageResponse, RedispatchBy +from .defaults import DEFAULT_TIME_ZONE +from .validations import validate_list + +# pylint: disable=missing-class-docstring,duplicate-code + + + + diff --git a/shapeshifter_uftp/uftp/agr_dso.py b/shapeshifter_uftp/uftp/agr_dso.py index 5ab6ef7..3ef3ff8 100644 --- a/shapeshifter_uftp/uftp/agr_dso.py +++ b/shapeshifter_uftp/uftp/agr_dso.py @@ -16,1192 +16,17 @@ # pylint: disable=missing-class-docstring,duplicate-code,too-many-lines -@dataclass(kw_only=True) -class ContractSettlementISP: - """ - :ivar start: Number of the first ISPs this element refers to. The - first ISP of a day has number 1. - :ivar duration: The number of the ISPs this element represents. - Optional, default value is 1. - :ivar reserved_power: Amount of flex power that has been reserved - (and not released using a FlexReservationUpdate message). - :ivar requested_power: Amount of flex power that has been both - reserved in advance and has been requested using a FlexRequest - (i.e. the lowest amount of flex power for this ISP). If there - was no FlexRequest, this field is omitted. - :ivar available_power: Amount of flex power that is considered - available based on the FlexRequest in question. In case - RequestedPower=0, AvailablePower is defined so that the offered - power is allowed to be between 0 and AvailablePower in terms of - compliancy (see Appendix 'Rationale for information exchange in - flexibility request' for details). In case RequestedPower ≠0, - AvailablePower is defined so that the offered power is allowed - to exceed the amount of requested power up to AvailablePower. If - this is relevant for settlement, the DSO can include this field. - :ivar offered_power: Amount of flex power that has been reserved in - advance, requested using a FlexRequest and covered in an offer - from the AGR. If there was no offer, this field is omitted. If - there were multiple offers, only the one is considered that is - most compliant . - :ivar ordered_power: Amount of flex power that has been ordered - using a FlexOrder message that was based on a FlexOffer, both - linked to this contract. If there was no order, this field is - omitted. - """ - class Meta: - name = "ContractSettlementISP" - start: int = field( - metadata={ - "name": "Start", - "type": "Attribute", - "required": True, - } - ) - duration: int = field( - default=1, - metadata={ - "name": "Duration", - "type": "Attribute", - } - ) - reserved_power: int = field( - metadata={ - "name": "ReservedPower", - "type": "Attribute", - "required": True, - } - ) - requested_power: Optional[int] = field( - default=None, - metadata={ - "name": "RequestedPower", - "type": "Attribute", - } - ) - available_power: Optional[int] = field( - default=None, - metadata={ - "name": "AvailablePower", - "type": "Attribute", - } - ) - offered_power: Optional[int] = field( - default=None, - metadata={ - "name": "OfferedPower", - "type": "Attribute", - } - ) - ordered_power: Optional[int] = field( - default=None, - metadata={ - "name": "OrderedPower", - "type": "Attribute", - } - ) -@dataclass(kw_only=True) -class DPrognosisISP: - """ - :ivar power: Power specified for this ISP in Watts. Also see the - important notes about the sign of this attribute in the main - documentation entry for the ISP element. - :ivar start: Number of the first ISPs this element refers to. The - first ISP of a day has number 1. - :ivar duration: The number of the ISPs this element represents. - Optional, default value is 1. - """ - class Meta: - name = "D-PrognosisISP" - power: int = field( - metadata={ - "name": "Power", - "type": "Attribute", - "required": True, - } - ) - start: int = field( - metadata={ - "name": "Start", - "type": "Attribute", - "required": True, - } - ) - duration: int = field( - default=1, - metadata={ - "name": "Duration", - "type": "Attribute", - } - ) -@dataclass(kw_only=True) -class FlexOfferOptionISP: - """ - :ivar power: Power specified for this ISP in Watts. Also see the - important notes about the sign of this attribute in the main - documentation entry for the ISP element. - :ivar start: Number of the first ISPs this element refers to. The - first ISP of a day has number 1. - :ivar duration: The number of the ISPs this element represents. - Optional, default value is 1. - """ - class Meta: - name = "FlexOfferOptionISP" - power: int = field( - metadata={ - "name": "Power", - "type": "Attribute", - "required": True, - } - ) - start: int = field( - metadata={ - "name": "Start", - "type": "Attribute", - "required": True, - } - ) - duration: int = field( - default=1, - metadata={ - "name": "Duration", - "type": "Attribute", - } - ) -@dataclass(kw_only=True) -class FlexOrderISP: - """ - :ivar power: Power specified for this ISP in Watts. Also see the - important notes about the sign of this attribute in the main - documentation entry for the ISP element. - :ivar start: Number of the first ISPs this element refers to. The - first ISP of a day has number 1. - :ivar duration: The number of the ISPs this element represents. - Optional, default value is 1. - """ - class Meta: - name = "FlexOrderISP" - power: int = field( - metadata={ - "name": "Power", - "type": "Attribute", - "required": True, - } - ) - start: int = field( - metadata={ - "name": "Start", - "type": "Attribute", - "required": True, - } - ) - duration: int = field( - default=1, - metadata={ - "name": "Duration", - "type": "Attribute", - } - ) -@dataclass(kw_only=True) -class FlexOrderSettlementISP: - """ - :ivar start: Number of the first ISPs this element refers to. The - first ISP of a day has number 1. - :ivar duration: The number of the ISPs this element represents. - Optional, default value is 1. - :ivar baseline_power: Power originally forecast (as per the - referenced baseline) for this ISP in Watts. - :ivar ordered_flex_power: Amount of flex power ordered (as per the - referenced FlexOrder message) for this ISP in Watts. - :ivar actual_power: Actual amount of power for this ISP in Watts, as - measured/determined by the DSO and allocated to the AGR. - :ivar delivered_flex_power: Actual amount of flex power delivered - for this ISP in Watts, as determined by the DSO. - :ivar power_deficiency: Amount of flex power sold but not delivered - for this ISP in Watts, as determined by the DSO. - """ - class Meta: - name = "FlexOrderSettlementISP" - start: int = field( - metadata={ - "name": "Start", - "type": "Attribute", - "required": True, - } - ) - duration: int = field( - default=1, - metadata={ - "name": "Duration", - "type": "Attribute", - } - ) - baseline_power: int = field( - metadata={ - "name": "BaselinePower", - "type": "Attribute", - "required": True, - } - ) - ordered_flex_power: int = field( - metadata={ - "name": "OrderedFlexPower", - "type": "Attribute", - "required": True, - } - ) - actual_power: int = field( - metadata={ - "name": "ActualPower", - "type": "Attribute", - "required": True, - } - ) - delivered_flex_power: int = field( - metadata={ - "name": "DeliveredFlexPower", - "type": "Attribute", - "required": True, - } - ) - power_deficiency: int = field( - default=0, - metadata={ - "name": "PowerDeficiency", - "type": "Attribute", - } - ) - -@dataclass(kw_only=True) -class FlexOrderStatus: - flex_order_message_id: str = field( - metadata={ - "name": "FlexOrderMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - is_validated: bool = field( - metadata={ - "name": "IsValidated", - "type": "Attribute", - "required": True, - } - ) - - -@dataclass(kw_only=True) -class FlexReservationUpdateISP: - """ - :ivar power: Remaining reserved power specified for this ISP in - Watts. - :ivar start: Number of the first ISPs this element refers to. The - first ISP of a day has number 1. - :ivar duration: The number of the ISPs this element represents. - Optional, default value is 1. - """ - class Meta: - name = "FlexReservationUpdateISP" - - power: int = field( - metadata={ - "name": "Power", - "type": "Attribute", - "required": True, - } - ) - start: int = field( - metadata={ - "name": "Start", - "type": "Attribute", - "required": True, - } - ) - duration: int = field( - default=1, - metadata={ - "name": "Duration", - "type": "Attribute", - } - ) - - -@dataclass(kw_only=True) -class ContractSettlementPeriod: - """ - :ivar isp: - :ivar period: Period the being settled. - """ - isps: List[ContractSettlementISP] = field( - default_factory=list, - metadata={ - "name": "ISP", - "type": "Element", - "min_occurs": 1, - } - ) - period: XmlDate = field( - metadata={ - "name": "Period", - "type": "Attribute", - "required": True, - } - ) - - -@dataclass(kw_only=True) -class DPrognosisResponse(PayloadMessageResponse): - class Meta: - name = "D-PrognosisResponse" - - d_prognosis_message_id: str = field( - metadata={ - "name": "D-PrognosisMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - flex_order_statuses: List[FlexOrderStatus] = field( - default_factory=list, - metadata={ - "name": "FlexOrderStatus", - "type": "Element", - } - ) - - -@dataclass(kw_only=True) -class FlexMessage(PayloadMessage): - """ - :ivar isp_duration: ISO 8601 time interval (minutes only, for - example PT15M) indicating the duration of the ISPs referenced in - this message. Although the ISP length is a market-wide fixed - value, making this assumption explicit in each message is - important for validation purposes, allowing implementations to - reject messages with an errant ISP duration. - :ivar time_zone: Time zone ID (as per the IANA time zone database, - http://www.iana.org/time-zones, for example: Europe/Amsterdam) - indicating the UTC offset that applies to the Period referenced - in this message. Although the time zone is a market-wide fixed - value, making this assumption explicit in each message is - important for validation purposes, allowing implementations to - reject messages with an errant UTC offset. - :ivar period: Day (in yyyy-mm-dd format) the ISPs referenced in this - Flex* message belong to. - :ivar congestion_point: Entity Address of the Congestion Point this - D-Prognosis applies to. - """ - isp_duration: XmlDuration = field( - metadata={ - "name": "ISP-Duration", - "type": "Attribute", - "required": True, - } - ) - time_zone: str = field( - default=DEFAULT_TIME_ZONE, - metadata={ - "name": "TimeZone", - "type": "Attribute", - "required": True, - "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", - } - ) - period: XmlDate = field( - metadata={ - "name": "Period", - "type": "Attribute", - "required": True, - } - ) - congestion_point: str = field( - metadata={ - "name": "CongestionPoint", - "type": "Attribute", - "required": True, - "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", - } - ) - - -@dataclass(kw_only=True) -class FlexOfferOption: - """ - :ivar isp: - :ivar option_reference: The identification of this option. - :ivar price: The asking price for the flexibility offered in this - option. - :ivar min_activation_factor: The minimal activation factor for this - OfferOption. An AGR may choose to include MinActivationFactor in - FlexOffers even if the DSO is not interested in partial - activation. In that case the DSO will simply use an - ActivationFactor of 1.00 in every FlexOrder. - """ - isps: List[FlexOfferOptionISP] = field( - default_factory=list, - metadata={ - "name": "ISP", - "type": "Element", - "min_occurs": 1, - } - ) - option_reference: str = field( - metadata={ - "name": "OptionReference", - "type": "Attribute", - "required": True, - } - ) - price: Decimal = field( - metadata={ - "name": "Price", - "type": "Attribute", - "required": True, - "fraction_digits": 4, - } - ) - min_activation_factor: Decimal = field( - default=Decimal("1.00"), - metadata={ - "name": "MinActivationFactor", - "type": "Attribute", - "min_inclusive": Decimal("0.01"), - "max_inclusive": Decimal("1.00"), - "fraction_digits": 2, - } - ) - - def __post_init__(self): - validate_list('isps', self.isps, FlexOfferOptionISP, 1) - self.price = validate_decimal('price', self.price, 4) - self.min_activation_factor = validate_decimal('min_activation_factor', self.min_activation_factor, 2) - - -@dataclass(kw_only=True) -class FlexOfferResponse(PayloadMessageResponse): - flex_offer_message_id: str = field( - metadata={ - "name": "FlexOfferMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - -@dataclass(kw_only=True) -class FlexOfferRevocationResponse(PayloadMessageResponse): - flex_offer_revocation_message_id: str = field( - metadata={ - "name": "FlexOfferRevocationMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - -@dataclass(kw_only=True) -class FlexOfferRevocation(PayloadMessage): - """ - :ivar flex_offer_message_id: MessageID of the FlexOffer message that - is being revoked: this FlexOffer must have been accepted - previously. - """ - flex_offer_message_id: str = field( - metadata={ - "name": "FlexOfferMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - -@dataclass(kw_only=True) -class FlexOrderResponse(PayloadMessageResponse): - flex_order_message_id: str = field( - metadata={ - "name": "FlexOrderMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - -@dataclass(kw_only=True) -class FlexOrderSettlementStatus: - """ - :ivar order_reference: Order reference assigned by the DSO when - originating the FlexOrder. - :ivar disposition: Indication whether the AGR accepts the order - settlement details provided by the DSO (and will invoice - accordingly), or disputes these details. - :ivar dispute_reason: In case the order settlement was disputed, - this attribute must contain a human-readable description of the - reason. - """ - order_reference: Optional[str] = field( - default=None, - metadata={ - "name": "OrderReference", - "type": "Attribute", - } - ) - disposition: AcceptedDisputed = field( - metadata={ - "name": "Disposition", - "type": "Attribute", - "required": True, - } - ) - dispute_reason: Optional[str] = field( - default=None, - metadata={ - "name": "DisputeReason", - "type": "Attribute", - } - ) - - -@dataclass(kw_only=True) -class FlexOrderSettlement: - """ - :ivar isp: - :ivar order_reference: Order reference assigned by the DSO when - originating the FlexOrder. - :ivar period: - :ivar contract_id: Reference to the concerning bilateral contract, - if it is linked to it - :ivar d_prognosis_message_id: MessageID of the Prognosis message - (more specifically: the D-Prognosis) the FlexOrder is based on, - if it has been agreed that the baseline is based on D-prognoses. - :ivar baseline_reference: Identification of the baseline prognosis, - if another baseline methodology is used than based on - D-prognoses. - :ivar congestion_point: Entity Address of the Congestion Point the - FlexOrder applies to. - :ivar price: The price accepted for supplying the ordered amount of - flexibility as per the referenced FlexOrder messages. - :ivar penalty: Penalty due a non-zero PowerDeficiency - :ivar net_settlement: Net settlement amount for this Period: Price - minus Penalty. - """ - isps: List[FlexOrderSettlementISP] = field( - default_factory=list, - metadata={ - "name": "ISP", - "type": "Element", - "min_occurs": 1, - } - ) - order_reference: Optional[str] = field( - default=None, - metadata={ - "name": "OrderReference", - "type": "Attribute", - } - ) - period: XmlDate = field( - metadata={ - "name": "Period", - "type": "Attribute", - "required": True, - } - ) - contract_id: Optional[str] = field( - default=None, - metadata={ - "name": "ContractID", - "type": "Attribute", - } - ) - d_prognosis_message_id: Optional[str] = field( - default=None, - metadata={ - "name": "D-PrognosisMessageID", - "type": "Attribute", - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - baseline_reference: Optional[str] = field( - default=None, - metadata={ - "name": "BaselineReference", - "type": "Attribute", - } - ) - congestion_point: str = field( - metadata={ - "name": "CongestionPoint", - "type": "Attribute", - "required": True, - "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", - } - ) - price: Decimal = field( - metadata={ - "name": "Price", - "type": "Attribute", - "required": True, - "fraction_digits": 4, - } - ) - penalty: Decimal = field( - default=Decimal("0"), - metadata={ - "name": "Penalty", - "type": "Attribute", - "fraction_digits": 4, - } - ) - net_settlement: Decimal = field( - metadata={ - "name": "NetSettlement", - "type": "Attribute", - "required": True, - "fraction_digits": 4, - } - ) - - def __post_init__(self): - validate_list('isps', self.isps, FlexOrderSettlementISP, 1) - self.price = validate_decimal('price', self.price, 4) - self.penalty = validate_decimal('penalty', self.penalty, 4) - self.net_settlement = validate_decimal('net_settlement', self.net_settlement, 4) - - -@dataclass(kw_only=True) -class FlexRequestISP: - """ - :ivar disposition: - :ivar min_power: Power specified for this ISP in Watts. Also see the - important notes about the sign of this attribute in the main - documentation entry for the ISP element. - :ivar max_power: Power specified for this ISP in Watts. Also see the - important notes about the sign of this attribute in the main - documentation entry for the ISP element. - :ivar start: Number of the first ISPs this element refers to. The - first ISP of a day has number 1. - :ivar duration: The number of the ISPs this element represents. - Optional, default value is 1. - """ - class Meta: - name = "FlexRequestISP" - - disposition: Optional[AvailableRequested] = field( - default=None, - metadata={ - "name": "Disposition", - "type": "Attribute", - } - ) - min_power: int = field( - metadata={ - "name": "MinPower", - "type": "Attribute", - "required": True, - } - ) - max_power: int = field( - metadata={ - "name": "MaxPower", - "type": "Attribute", - "required": True, - } - ) - start: int = field( - metadata={ - "name": "Start", - "type": "Attribute", - "required": True, - } - ) - duration: int = field( - default=1, - metadata={ - "name": "Duration", - "type": "Attribute", - } - ) - - -@dataclass(kw_only=True) -class FlexRequestResponse(PayloadMessageResponse): - - flex_request_message_id: str = field( - metadata={ - "name": "FlexRequestMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - -@dataclass(kw_only=True) -class FlexReservationUpdateResponse(PayloadMessageResponse): - - flex_reservation_update_message_id: str = field( - metadata={ - "name": "FlexReservationUpdateMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - -@dataclass(kw_only=True) -class ContractSettlement: - """ - :ivar period: - :ivar contract_id: Reference to the concerning bilateral contract. - """ - periods: List[ContractSettlementPeriod] = field( - default_factory=list, - metadata={ - "name": "Period", - "type": "Element", - "min_occurs": 1, - } - ) - contract_id: Optional[str] = field( - default=None, - metadata={ - "name": "ContractID", - "type": "Attribute", - } - ) - - def __post_init__(self): - validate_list('periods', self.periods, ContractSettlementPeriod, 1) - - -@dataclass(kw_only=True) -class DPrognosis(FlexMessage): - """ - :ivar isp: - :ivar revision: Revision of this message. A sequence number that - must be incremented each time a new revision of a prognosis is - sent. The combination of SenderDomain and PrognosisSequence - should be unique - """ - class Meta: - name = "D-Prognosis" - - isps: List[DPrognosisISP] = field( - default_factory=list, - metadata={ - "name": "ISP", - "type": "Element", - "min_occurs": 1, - } - ) - revision: int = field( - metadata={ - "name": "Revision", - "type": "Attribute", - "required": True, - } - ) - - def __post_init__(self): - validate_list('isps', self.isps, DPrognosisISP, 1) - - -@dataclass(kw_only=True) -class FlexOffer(FlexMessage): - """ - :ivar offer_option: - :ivar expiration_date_time: Date and time, including the time zone - (ISO 8601 formatted as per http://www.w3.org/TR/NOTE-datetime) - until which the FlexOffer is valid. - :ivar flex_request_message_id: MessageID of the FlexRequest message - this request is based on. Mandatory if and only if solicited. - :ivar contract_id: Reference to the concerning contract, if - applicable. The contract may be either bilateral or commoditized - market contract. - :ivar d_prognosis_message_id: MessageID of the D-Prognosis this - request is based on, if it has been agreed that the baseline is - based on D-prognoses. - :ivar baseline_reference: Identification of the baseline prognosis, - if another baseline methodology is used than based on - D-prognoses - :ivar currency: ISO 4217 code indicating the currency that applies - to the price of the FlexOffer. - """ - offer_options: List[FlexOfferOption] = field( - default_factory=list, - metadata={ - "name": "OfferOption", - "type": "Element", - "min_occurs": 1, - } - ) - expiration_date_time: str = field( - metadata={ - "name": "ExpirationDateTime", - "type": "Attribute", - "required": True, - "pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)", - } - ) - flex_request_message_id: Optional[str] = field( - default=None, - metadata={ - "name": "FlexRequestMessageID", - "type": "Attribute", - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - contract_id: Optional[str] = field( - default=None, - metadata={ - "name": "ContractID", - "type": "Attribute", - } - ) - d_prognosis_message_id: Optional[str] = field( - default=None, - metadata={ - "name": "D-PrognosisMessageID", - "type": "Attribute", - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - baseline_reference: Optional[str] = field( - default=None, - metadata={ - "name": "BaselineReference", - "type": "Attribute", - } - ) - currency: str = field( - default="EUR", - metadata={ - "name": "Currency", - "type": "Attribute", - "required": True, - "pattern": r"[A-Z]{3}", - } - ) - - def __post_init__(self): - validate_list('offer_options', self.offer_options, FlexOfferOption, 1) - - -@dataclass(kw_only=True) -class FlexOrder(FlexMessage): - """ - :ivar isp: - :ivar flex_offer_message_id: MessageID of the FlexOffer message this - order is based on. - :ivar contract_id: Reference to the concerning bilateral contract, - if applicable. - :ivar d_prognosis_message_id: MessageID of the D-Prognosis this - request is based on, if it has been agreed that the baseline is - based on D-prognoses. - :ivar baseline_reference: Identification of the baseline prognosis, - if another baseline methodology is used than based on - D-prognoses - :ivar price: The price for the flexibility ordered. Usually, the - price should match the price of the related FlexOffer. - :ivar currency: ISO 4217 code indicating the currency that applies - to the price of the FlexOffer. - :ivar order_reference: Order number assigned by the DSO originating - the FlexOrder. To be stored by the AGR and used in the - settlement phase. - :ivar option_reference: The OptionReference from the OfferOption - chosen from the FlexOffer. - :ivar activation_factor: The activation factor for this OfferOption. - The ActivationFactor must be greater than or equal to the - MinActivationFactor in the OfferOption chosen from the - FlexOffer. - """ - isps: List[FlexOrderISP] = field( - default_factory=list, - metadata={ - "name": "ISP", - "type": "Element", - "min_occurs": 1, - } - ) - flex_offer_message_id: str = field( - metadata={ - "name": "FlexOfferMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - contract_id: Optional[str] = field( - default=None, - metadata={ - "name": "ContractID", - "type": "Attribute", - } - ) - d_prognosis_message_id: Optional[str] = field( - default=None, - metadata={ - "name": "D-PrognosisMessageID", - "type": "Attribute", - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - baseline_reference: Optional[str] = field( - default=None, - metadata={ - "name": "BaselineReference", - "type": "Attribute", - } - ) - price: Decimal = field( - metadata={ - "name": "Price", - "type": "Attribute", - "required": True, - "fraction_digits": 4, - } - ) - currency: str = field( - metadata={ - "name": "Currency", - "type": "Attribute", - "required": True, - "pattern": r"[A-Z]{3}", - } - ) - order_reference: str = field( - metadata={ - "name": "OrderReference", - "type": "Attribute", - "required": True, - } - ) - option_reference: Optional[str] = field( - default=None, - metadata={ - "name": "OptionReference", - "type": "Attribute", - } - ) - activation_factor: Decimal = field( - default=Decimal("1.00"), - metadata={ - "name": "ActivationFactor", - "type": "Attribute", - "min_inclusive": Decimal("0.01"), - "max_inclusive": Decimal("1.00"), - "fraction_digits": 2, - } - ) - - def __post_init__(self): - validate_list("isps", self.isps, FlexOrderISP, 1) - self.price = validate_decimal("price", self.price, 4) - self.activation_factor = validate_decimal( - "activation_factor", self.activation_factor, 2 - ) - -@dataclass(kw_only=True) -class FlexRequest(FlexMessage): - """ - :ivar isp: - :ivar revision: Revision of this message, a sequence number that - must be incremented each time a new revision of a FlexRequest - message is sent. - :ivar expiration_date_time: Date and time, including the time zone - (ISO 8601 formatted as per http://www.w3.org/TR/NOTE-datetime) - until which the FlexRequest message is valid. - :ivar contract_id: Reference to the concerning contract, if - applicable. The contract may be either bilateral or commoditized - market contract. Each contract may specify multiple service- - types. - :ivar service_type: Service type for this request, the service type - determines response characteristics such as latency or asset - participation type. - """ - isps: List[FlexRequestISP] = field( - default_factory=list, - metadata={ - "name": "ISP", - "type": "Element", - "min_occurs": 1, - } - ) - revision: int = field( - metadata={ - "name": "Revision", - "type": "Attribute", - "required": True, - } - ) - expiration_date_time: str = field( - metadata={ - "name": "ExpirationDateTime", - "type": "Attribute", - "required": True, - "pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)", - } - ) - contract_id: Optional[str] = field( - default=None, - metadata={ - "name": "ContractID", - "type": "Attribute", - } - ) - service_type: Optional[str] = field( - default=None, - metadata={ - "name": "ServiceType", - "type": "Attribute", - } - ) - - def __post_init__(self): - validate_list('isps', self.isps, FlexRequestISP, 1) - - -@dataclass(kw_only=True) -class FlexReservationUpdate(FlexMessage): - """ - :ivar isp: - :ivar contract_id: Reference to the bilateral contract in question. - :ivar reference: Message reference, assigned by the DSO originating - the FlexReservationUpdate. - """ - isps: List[FlexReservationUpdateISP] = field( - default_factory=list, - metadata={ - "name": "ISP", - "type": "Element", - "min_occurs": 1, - } - ) - contract_id: str = field( - metadata={ - "name": "ContractID", - "type": "Attribute", - "required": True, - } - ) - reference: str = field( - metadata={ - "name": "Reference", - "type": "Attribute", - "required": True, - } - ) - - def __post_init__(self): - validate_list('isps', self.isps, FlexReservationUpdateISP, 1) - - -@dataclass(kw_only=True) -class FlexSettlementResponse(PayloadMessageResponse): - flex_settlement_message_id: str = field( - metadata={ - "name": "FlexSettlementMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - flex_order_settlement_statuses: List[FlexOrderSettlementStatus] = field( - default_factory=list, - metadata={ - "name": "FlexOrderSettlementStatus", - "type": "Element", - "min_occurs": 1, - } - ) - - def __post_init__(self): - validate_list( - "flex_order_settlement_statuses", - self.flex_order_settlement_statuses, - FlexOrderSettlementStatus, - 1, - ) - - -@dataclass(kw_only=True) -class FlexSettlement(PayloadMessageResponse): - """ - :ivar flex_order_settlement: - :ivar contract_settlement: - :ivar period_start: First Period of the settlement period this - message applies to. - :ivar period_end: Last Period of the settlement period this message - applies to. - :ivar currency: ISO 4217 code indicating the currency that applies - to all amounts (flex price, penalty and net settlement) in this - message. - """ - flex_order_settlements: List[FlexOrderSettlement] = field( - default_factory=list, - metadata={ - "name": "FlexOrderSettlement", - "type": "Element", - "min_occurs": 1, - } - ) - contract_settlements: List[ContractSettlement] = field( - default_factory=list, - metadata={ - "name": "ContractSettlement", - "type": "Element", - "min_occurs": 1, - } - ) - period_start: XmlDate = field( - metadata={ - "name": "PeriodStart", - "type": "Attribute", - "required": True, - } - ) - period_end: XmlDate = field( - metadata={ - "name": "PeriodEnd", - "type": "Attribute", - "required": True, - } - ) - currency: str = field( - metadata={ - "name": "Currency", - "type": "Attribute", - "required": True, - "pattern": r"[A-Z]{3}", - } - ) - - def __post_init__(self): - validate_list( - "flex_order_settlements", self.flex_order_settlements, FlexOrderSettlement, 1 - ) - validate_list( - "contract_settlements", self.contract_settlements, ContractSettlement, 1 - ) diff --git a/shapeshifter_uftp/uftp/common.py b/shapeshifter_uftp/uftp/common.py index 1d94e48..d861eb7 100644 --- a/shapeshifter_uftp/uftp/common.py +++ b/shapeshifter_uftp/uftp/common.py @@ -4,202 +4,3 @@ # pylint: disable=missing-class-docstring,duplicate-code - -class AcceptedDisputed(Enum): - ACCEPTED = "Accepted" - DISPUTED = "Disputed" - - -class AcceptedRejected(Enum): - ACCEPTED = "Accepted" - REJECTED = "Rejected" - - -class AvailableRequested(Enum): - AVAILABLE = "Available" - REQUESTED = "Requested" - - -@dataclass(kw_only=True) -class PayloadMessage: - """ - :ivar version: Version of the Shapeshifter specification used by the - USEF participant sending this message. - :ivar sender_domain: The Internet domain of the USEF participant - sending this message. When receiving a message, its value should - match the value specified in the SignedMessage wrapper: - otherwise, the message must be rejected as invalid. When - replying to this message, this attribute is used to look up the - USEF endpoint the reply message should be delivered to. - :ivar recipient_domain: Internet domain of the participant this - message is intended for. When sending a message, this attribute, - combined with the RecipientRole, is used to look up the USEF - endpoint the message should be delivered to. - :ivar time_stamp: Date and time this message was created, including - the time zone (ISO 8601 formatted as per - http://www.w3.org/TR/NOTE-datetime). - :ivar message_id: Unique identifier (UUID/GUID as per IETF RFC 4122) - for this message, to be generated when composing each message. - :ivar conversation_id: Unique identifier (UUID/GUID as per IETF RFC - 4122) used to correlate responses with requests, to be generated - when composing the first message in a conversation and - subsequently copied from the original message to each reply - message. - """ - - version: Optional[str] = field( - default="3.0.0", - metadata={ - "name": "Version", - "type": "Attribute", - "required": True, - "pattern": r"(\d+\.\d+\.\d+)", - } - ) - sender_domain: Optional[str] = field( - default=None, - metadata={ - "name": "SenderDomain", - "type": "Attribute", - "required": True, - "pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}", - } - ) - recipient_domain: Optional[str] = field( - default=None, - metadata={ - "name": "RecipientDomain", - "type": "Attribute", - "required": True, - "pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}", - } - ) - time_stamp: Optional[str] = field( - default=None, - metadata={ - "name": "TimeStamp", - "type": "Attribute", - "required": True, - "pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)", - } - ) - message_id: Optional[str] = field( - default=None, - metadata={ - "name": "MessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - conversation_id: Optional[str] = field( - default=None, - metadata={ - "name": "ConversationID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - -class RedispatchBy(Enum): - AGR = "AGR" - DSO = "DSO" - - -class UsefRole(Enum): - AGR = "AGR" - CRO = "CRO" - DSO = "DSO" - - -@dataclass(kw_only=True) -class PayloadMessageResponse(PayloadMessage): - """ - :ivar reference_message_id: MessageID of the message that has just - been accepted or rejected. - :ivar result: Indication whether the query was executed successfully - or failed. - :ivar rejection_reason: In case the query failed, this attribute - must contain a human-readable description of the failure reason. - """ - - result: Optional[AcceptedRejected] = field( - default=AcceptedRejected.ACCEPTED, - metadata={ - "name": "Result", - "type": "Attribute", - "required": True, - } - ) - rejection_reason: Optional[str] = field( - default=None, - metadata={ - "name": "RejectionReason", - "type": "Attribute", - }, - ) - - -@dataclass(kw_only=True) -class SignedMessage: - """The SignedMessage element represents the secure wrapper used to submit USEF - XML messages from the local message queue to the message queue of a remote - participant. - - It contains minimal metadata (which is distinct from the common - metadata used for all other messages), allowing the recipient to - look up the sender's cryptographic scheme and public keys, and the - actual XML message, as transformed (signed/sealed) using that - cryptographic scheme. - - :ivar sender_domain: The Internet domain of the USEF participant - sending this message. Upon receiving a message, the recipient - should validate that its value matches the corresponding - attribute value specified in the inner XML message, once un- - sealed: if not, the message must be rejected as invalid. - :ivar sender_role: The USEF role of the participant sending this - message: AGR, BRP, CRO, DSO or MDC. Receive-time validation - should take place as described for the SenderDomain attribute - above. - :ivar body: The Base-64 encoded inner XML message contained in this - wrapper, as transformed (signed/sealed) using the sender's - cryptographic scheme. The recipient can determine which scheme - applies using a DNS or configuration file lookup, based on the - combination of SenderDomain and SenderRole. - """ - - sender_domain: str = field( - metadata={ - "name": "SenderDomain", - "type": "Attribute", - "required": True, - "pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}", - } - ) - sender_role: UsefRole = field( - metadata={ - "name": "SenderRole", - "type": "Attribute", - "required": True, - } - ) - body: bytes = field( - metadata={ - "name": "Body", - "type": "Attribute", - "required": True, - "format": "base64", - } - ) - - -@dataclass(kw_only=True) -class TestMessage(PayloadMessage): - __test__ = False # Tell pytest to ignore this class - - -@dataclass(kw_only=True) -class TestMessageResponse(PayloadMessageResponse): - __test__ = False # Tell pytest to ignore this class diff --git a/shapeshifter_uftp/uftp/cro_dso.py b/shapeshifter_uftp/uftp/cro_dso.py index 3e97610..9c95a6a 100644 --- a/shapeshifter_uftp/uftp/cro_dso.py +++ b/shapeshifter_uftp/uftp/cro_dso.py @@ -1,332 +1,11 @@ -from dataclasses import dataclass, field -from typing import List, Optional - -from xsdata.models.datatype import XmlDate - -from .common import PayloadMessage, PayloadMessageResponse, RedispatchBy -from .defaults import DEFAULT_TIME_ZONE -from .validations import validate_list - -# pylint: disable=missing-class-docstring,duplicate-code - - -@dataclass(kw_only=True) -class DsoPortfolioQueryConnection: - """ - A Connection that is part of the congestion point. - - :ivar entity_address: EntityAddress of the Connection. - :ivar agr_domain: The internet domain of the AGR that represents the - prosumer connected on this Connection, if applicable. - """ - class Meta: - name = "DSOPortfolioQueryConnection" - - entity_address: str = field( - metadata={ - "name": "EntityAddress", - "type": "Attribute", - "required": True, - "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", - } - ) - agr_domain: Optional[str] = field( - default=None, - metadata={ - "name": "AGR-Domain", - "type": "Attribute", - "pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}", - } - ) - - -@dataclass(kw_only=True) -class DsoPortfolioUpdateConnection: - """ - A connection that the DSO wants the CRO to update. - - :ivar entity_address: EntityAddress of the Connection. - :ivar start_period: The first Period that the Connection is part of - this CongestionPoint. - :ivar end_period: The last Period that the Connection is part of - this CongestionPoint. - """ - class Meta: - name = "DSOPortfolioUpdateConnection" - - entity_address: str = field( - metadata={ - "name": "EntityAddress", - "type": "Attribute", - "required": True, - "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", - } - ) - start_period: XmlDate = field( - metadata={ - "name": "StartPeriod", - "type": "Attribute", - "required": True, - } - ) - end_period: Optional[XmlDate] = field( - default=None, - metadata={ - "name": "EndPeriod", - "type": "Attribute", - } - ) - - -@dataclass(kw_only=True) -class DsoPortfolioQueryCongestionPoint: - """ - :ivar connection: - :ivar entity_address: EntityAddress of the Connection. - """ - class Meta: - name = "DSOPortfolioQueryCongestionPoint" - - connections: List[DsoPortfolioQueryConnection] = field( - default_factory=list, - metadata={ - "name": "Connection", - "type": "Element", - "min_occurs": 1, - } - ) - entity_address: str = field( - metadata={ - "name": "EntityAddress", - "type": "Attribute", - "required": True, - "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", - } - ) - - def __post_init__(self): - validate_list('connections', self.connections, DsoPortfolioQueryConnection, 1) - - -@dataclass(kw_only=True) -class DsoPortfolioQuery(PayloadMessage): - """ - :ivar time_zone: Time zone ID (as per the IANA time zone database, - http://www.iana.org/time-zones, for example: Europe/Amsterdam) - indicating the UTC offset that applies to the Period referenced - in this message. Although the time zone is a market-wide fixed - value, making this assumption explicit in each message is - important for validation purposes, allowing implementations to - reject messages with an errant UTC offset. - :ivar period: The Period for which the AGR requests the portfolio - information. - :ivar entity_address: EntityAddress of the CongestionPoint - """ - class Meta: - name = "DSOPortfolioQuery" - - time_zone: str = field( - default=DEFAULT_TIME_ZONE, - metadata={ - "name": "TimeZone", - "type": "Attribute", - "required": True, - "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", - } - ) - period: XmlDate = field( - metadata={ - "name": "Period", - "type": "Attribute", - "required": True, - } - ) - entity_address: str = field( - metadata={ - "name": "EntityAddress", - "type": "Attribute", - "required": True, - "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", - } - ) - - -@dataclass(kw_only=True) -class DsoPortfolioUpdateCongestionPoint: - """ - A congestion point that the DSO wants the CRO to update. - - :ivar connection: - :ivar entity_address: EntityAddress of the Connection. - :ivar start_period: The first Period that the Connection is part of - this CongestionPoint. - :ivar end_period: The last Period that the Connection is part of - this CongestionPoint. - :ivar mutex_offers_supported: Indicates whether the DSO accepts - mutual exclusive FlexOffers on this CongestionPoint. - :ivar day_ahead_redispatch_by: Indicates which party is responsible - for day-ahead redispatch. - :ivar intraday_redispatch_by: Indicates which party is responsible - for intraday ahead redispatch, AGR or DSO. If not specified, - there will be no intraday trading on this CongestionPoint. - """ - class Meta: - name = "DSOPortfolioUpdateCongestionPoint" - - connections: List[DsoPortfolioUpdateConnection] = field( - default_factory=list, - metadata={ - "name": "Connection", - "type": "Element", - "min_occurs": 1, - } - ) - entity_address: str = field( - metadata={ - "name": "EntityAddress", - "type": "Attribute", - "required": True, - "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", - } - ) - start_period: XmlDate = field( - metadata={ - "name": "StartPeriod", - "type": "Attribute", - "required": True, - } - ) - end_period: Optional[XmlDate] = field( - default=None, - metadata={ - "name": "EndPeriod", - "type": "Attribute", - } - ) - mutex_offers_supported: bool = field( - metadata={ - "name": "MutexOffersSupported", - "type": "Attribute", - "required": True, - } - ) - day_ahead_redispatch_by: RedispatchBy = field( - metadata={ - "name": "DayAheadRedispatchBy", - "type": "Attribute", - "required": True, - } - ) - intraday_redispatch_by: Optional[RedispatchBy] = field( - default=None, - metadata={ - "name": "IntradayRedispatchBy", - "type": "Attribute", - } - ) - - def __post_init__(self): - validate_list('connections', self.connections, DsoPortfolioUpdateConnection, 1) - - -@dataclass(kw_only=True) -class DsoPortfolioUpdateResponse(PayloadMessageResponse): - class Meta: - name = "DSOPortfolioUpdateResponse" - - dso_portfolio_update_message_id: str = field( - metadata={ - "name": "DSOPortfolioUpdateResponseMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - -@dataclass(kw_only=True) -class DsoPortfolioQueryResponse(PayloadMessageResponse): - """ - :ivar congestion_point: - :ivar time_zone: Time zone ID (as per the IANA time zone database, - http://www.iana.org/time-zones, for example: Europe/Amsterdam) - indicating the UTC offset that applies to the Period referenced - in this message. Although the time zone is a market-wide fixed - value, making this assumption explicit in each message is - important for validation purposes, allowing implementations to - reject messages with an errant UTC offset. - :ivar period: The Period for which the AGR requests the portfolio - information. - """ - class Meta: - name = "DSOPortfolioQueryResponse" - - dso_portfolio_query_message_id: str = field( - metadata={ - "name": "DSOPortfolioQueryMessageID", - "type": "Attribute", - "required": True, - "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", - } - ) - - congestion_point: Optional[DsoPortfolioQueryCongestionPoint] = field( - default=None, - metadata={ - "name": "CongestionPoint", - "type": "Element", - } - ) - time_zone: str = field( - default=DEFAULT_TIME_ZONE, - metadata={ - "name": "TimeZone", - "type": "Attribute", - "required": True, - "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", - } - ) - period: XmlDate = field( - metadata={ - "name": "Period", - "type": "Attribute", - "required": True, - } - ) - - -@dataclass(kw_only=True) -class DsoPortfolioUpdate(PayloadMessage): - """ - :ivar congestion_point: - :ivar time_zone: Time zone ID (as per the IANA time zone database, - http://www.iana.org/time-zones, for example: Europe/Amsterdam) - indicating the UTC offset that applies to the Period referenced - in this message. Although the time zone is a market-wide fixed - value, making this assumption explicit in each message is - important for validation purposes, allowing implementations to - reject messages with an errant UTC offset. - """ - class Meta: - name = "DSOPortfolioUpdate" - - congestion_points: List[DsoPortfolioUpdateCongestionPoint] = field( - default_factory=list, - metadata={ - "name": "CongestionPoint", - "type": "Element", - "min_occurs": 1, - } - ) - time_zone: str = field( - default=DEFAULT_TIME_ZONE, - metadata={ - "name": "TimeZone", - "type": "Attribute", - "required": True, - "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", - } - ) - - def __post_init__(self): - validate_list('congestion_points', self.congestion_points, DsoPortfolioUpdateCongestionPoint, 1) +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate + +from .common import PayloadMessage, PayloadMessageResponse, RedispatchBy +from .defaults import DEFAULT_TIME_ZONE +from .validations import validate_list + +# pylint: disable=missing-class-docstring,duplicate-code + diff --git a/shapeshifter_uftp/uftp/enums.py b/shapeshifter_uftp/uftp/enums.py new file mode 100644 index 0000000..9a8b41d --- /dev/null +++ b/shapeshifter_uftp/uftp/enums.py @@ -0,0 +1,26 @@ +from enum import StrEnum + + +class AcceptedDisputed(StrEnum): + ACCEPTED = "Accepted" + DISPUTED = "Disputed" + + +class AcceptedRejected(StrEnum): + ACCEPTED = "Accepted" + REJECTED = "Rejected" + + +class AvailableRequested(StrEnum): + AVAILABLE = "Available" + REQUESTED = "Requested" + + +class RedispatchBy(StrEnum): + AGR = "AGR" + DSO = "DSO" + +class UsefRole(StrEnum): + AGR = "AGR" + CRO = "CRO" + DSO = "DSO" diff --git a/shapeshifter_uftp/uftp/messages/__init__.py b/shapeshifter_uftp/uftp/messages/__init__.py new file mode 100644 index 0000000..973fc30 --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/__init__.py @@ -0,0 +1,16 @@ +from .agr_portfolio_query import * +from .agr_portfolio_update import * +from .d_prognosis import * +from .dso_portfolio_query import * +from .dso_portfolio_update import * +from .flex_message import * +from .flex_offer import * +from .flex_offer_revocation import * +from .flex_order import * +from .flex_request import * +from .flex_reservation_update import * +from .flex_settlement import * +from .metering import * +from .payload_message import * +from .signed_message import * +from .test_message import * diff --git a/shapeshifter_uftp/uftp/messages/agr_portfolio_query.py b/shapeshifter_uftp/uftp/messages/agr_portfolio_query.py new file mode 100644 index 0000000..9376f57 --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/agr_portfolio_query.py @@ -0,0 +1,231 @@ +from dataclasses import dataclass, field +from decimal import Decimal +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import RedispatchBy +from ..validations import validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class AgrPortfolioQueryResponseConnection: + """ + :ivar entity_address: EntityAddress of the Connection. + """ + class Meta: + name = "AGRPortfolioQueryResponseConnection" + + entity_address: str = field( + metadata={ + "name": "EntityAddress", + "type": "Attribute", + "required": True, + "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", + } + ) + +@dataclass(kw_only=True) +class AgrPortfolioQueryResponseCongestionPoint: + """ + :ivar connection: + :ivar entity_address: EntityAddress of the CongestionPoint. + :ivar mutex_offers_supported: Indicates whether the DSO accepts + mutual exclusive FlexOffers on this CongestionPoint. + :ivar day_ahead_redispatch_by: Indicates which party is responsible + for day-ahead redispatch. + :ivar intraday_redispatch_by: Indicates which party is responsible + for intraday ahead redispatch, AGR or DSO. If not specified, + there will be no intraday trading on this CongestionPoint. + """ + class Meta: + name = "AGRPortfolioQueryResponseCongestionPoint" + + connections: List[AgrPortfolioQueryResponseConnection] = field( + default_factory=list, + metadata={ + "name": "Connection", + "type": "Element", + "min_occurs": 1, + } + ) + entity_address: str = field( + metadata={ + "name": "EntityAddress", + "type": "Attribute", + "required": True, + "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", + } + ) + mutex_offers_supported: bool = field( + metadata={ + "name": "MutexOffersSupported", + "type": "Attribute", + "required": True, + } + ) + day_ahead_redispatch_by: RedispatchBy = field( + metadata={ + "name": "DayAheadRedispatchBy", + "type": "Attribute", + "required": True, + } + ) + intraday_redispatch_by: Optional[RedispatchBy] = field( + default=None, + metadata={ + "name": "IntradayRedispatchBy", + "type": "Attribute", + } + ) + + def __post_init__(self): + validate_list('connections', self.connections, AgrPortfolioQueryResponseConnection, 1) + + + + + +@dataclass(kw_only=True) +class AgrPortfolioQueryResponseDSOPortfolio: + class Meta: + name = "AGRPortfolioQueryResponseDSOPortfolio" + + congestion_points: List[AgrPortfolioQueryResponseCongestionPoint] = field( + default_factory=list, + metadata={ + "name": "CongestionPoint", + "type": "Element", + "min_occurs": 1, + } + ) + dso_domain: str = field( + metadata={ + "name": "DSO-Domain", + "type": "Attribute", + "required": True, + "pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}", + } + ) + + def __post_init__(self): + validate_list('congestion_points', self.congestion_points, AgrPortfolioQueryResponseCongestionPoint, 1) + + +@dataclass(kw_only=True) +class AgrPortfolioQueryResponseDSOView: + class Meta: + name = "AGRPortfolioQueryResponseDSOView" + + dso_portfolios: List[AgrPortfolioQueryResponseDSOPortfolio] = field( + default_factory=list, + metadata={ + "name": "DSO-Portfolio", + "type": "Element", + "min_occurs": 1, + } + ) + connections: List[AgrPortfolioQueryResponseConnection] = field( + default_factory=list, + metadata={ + "name": "Connection", + "type": "Element", + } + ) + + def __post_init__(self): + validate_list('dso_portfolios', self.dso_portfolios, AgrPortfolioQueryResponseDSOPortfolio, 1) + + + + +@dataclass(kw_only=True) +class AgrPortfolioQueryResponse(PayloadMessageResponse): + """ + :ivar dso_view: + :ivar time_zone: Time zone ID (as per the IANA time zone database, + http://www.iana.org/time-zones, for example: Europe/Amsterdam) + indicating the UTC offset that applies to the Period referenced + in this message. Although the time zone is a market-wide fixed + value, making this assumption explicit in each message is + important for validation purposes, allowing implementations to + reject messages with an errant UTC offset. + :ivar period: The Period that the portfolio is valid. + """ + class Meta: + name = "AGRPortfolioQueryResponse" + + agr_portfolio_query_message_id: str = field( + metadata={ + "name": "AGRPortfolioQueryMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + + dso_views: List[AgrPortfolioQueryResponseDSOView] = field( + default_factory=list, + metadata={ + "name": "DSO-View", + "type": "Element", + "min_occurs": 1, + } + ) + time_zone: str = field( + default=DEFAULT_TIME_ZONE, + metadata={ + "name": "TimeZone", + "type": "Attribute", + "required": True, + "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", + } + ) + period: XmlDate = field( + metadata={ + "name": "Period", + "type": "Attribute", + "required": True, + } + ) + + def __post_init__(self): + self.dso_views = validate_list('dso_views', self.dso_views, AgrPortfolioQueryResponseDSOView, 1) + + +@dataclass(kw_only=True) +class AgrPortfolioQuery(PayloadMessage): + """ + :ivar time_zone: Time zone ID (as per the IANA time zone database, + http://www.iana.org/time-zones, for example: Europe/Amsterdam) + indicating the UTC offset that applies to the Period referenced + in this message. Although the time zone is a market-wide fixed + value, making this assumption explicit in each message is + important for validation purposes, allowing implementations to + reject messages with an errant UTC offset. + :ivar period: The Period for which the AGR requests the portfolio + information. + """ + + class Meta: + name = "AGRPortfolioQuery" + + time_zone: str = field( + default=DEFAULT_TIME_ZONE, + metadata={ + "name": "TimeZone", + "type": "Attribute", + "required": True, + "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", + } + ) + period: XmlDate = field( + metadata={ + "name": "Period", + "type": "Attribute", + "required": True, + } + ) diff --git a/shapeshifter_uftp/uftp/messages/agr_portfolio_update.py b/shapeshifter_uftp/uftp/messages/agr_portfolio_update.py new file mode 100644 index 0000000..a666c1a --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/agr_portfolio_update.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import RedispatchBy +from ..validations import validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class AgrPortfolioUpdateConnection: + """ + A connection that the AGR want the CRO to update. + + :ivar entity_address: EntityAddress of the Connection entity being + updated. + :ivar start_period: The first Period hat the AGR represents the + prosumer at this Connection. + :ivar end_period: The last Period that the AGR represents the + prosumer at this Connection, if applicable. + """ + class Meta: + name = "AGRPortfolioUpdateConnection" + + entity_address: str = field( + metadata={ + "name": "EntityAddress", + "type": "Attribute", + "required": True, + "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", + } + ) + start_period: XmlDate = field( + metadata={ + "name": "StartPeriod", + "type": "Attribute", + "required": True, + } + ) + end_period: Optional[XmlDate] = field( + default=None, + metadata={ + "name": "EndPeriod", + "type": "Attribute", + } + ) + + +@dataclass(kw_only=True) +class AgrPortfolioUpdateResponse(PayloadMessageResponse): + class Meta: + name = "AGRPortfolioUpdateResponse" + + agr_portfolio_update_message_id: str = field( + metadata={ + "name": "AGRPortfolioUpdateMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + + +@dataclass(kw_only=True) +class AgrPortfolioUpdate(PayloadMessage): + """ + :ivar connection: + :ivar time_zone: Time zone ID (as per the IANA time zone database, + http://www.iana.org/time-zones, for example: Europe/Amsterdam) + indicating the UTC offset that applies to the Period referenced + in this message. Although the time zone is a market-wide fixed + value, making this assumption explicit in each message is + important for validation purposes, allowing implementations to + reject messages with an errant UTC offset. + """ + class Meta: + name = "AGRPortfolioUpdate" + + connections: List[AgrPortfolioUpdateConnection] = field( + default_factory=list, + metadata={ + "name": "Connection", + "type": "Element", + "min_occurs": 1, + } + ) + time_zone: str = field( + default=DEFAULT_TIME_ZONE, + metadata={ + "name": "TimeZone", + "type": "Attribute", + "required": True, + "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", + } + ) + + def __post_init__(self): + validate_list('connections', self.connections, AgrPortfolioUpdateConnection, 1) diff --git a/shapeshifter_uftp/uftp/messages/d_prognosis.py b/shapeshifter_uftp/uftp/messages/d_prognosis.py new file mode 100644 index 0000000..8a46f04 --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/d_prognosis.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import RedispatchBy +from ..validations import validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class DPrognosisISP: + """ + :ivar power: Power specified for this ISP in Watts. Also see the + important notes about the sign of this attribute in the main + documentation entry for the ISP element. + :ivar start: Number of the first ISPs this element refers to. The + first ISP of a day has number 1. + :ivar duration: The number of the ISPs this element represents. + Optional, default value is 1. + """ + class Meta: + name = "D-PrognosisISP" + + power: int = field( + metadata={ + "name": "Power", + "type": "Attribute", + "required": True, + } + ) + start: int = field( + metadata={ + "name": "Start", + "type": "Attribute", + "required": True, + } + ) + duration: int = field( + default=1, + metadata={ + "name": "Duration", + "type": "Attribute", + } + ) + + +@dataclass(kw_only=True) +class FlexOrderStatus: + flex_order_message_id: str = field( + metadata={ + "name": "FlexOrderMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + is_validated: bool = field( + metadata={ + "name": "IsValidated", + "type": "Attribute", + "required": True, + } + ) + + +@dataclass(kw_only=True) +class DPrognosisResponse(PayloadMessageResponse): + class Meta: + name = "D-PrognosisResponse" + + d_prognosis_message_id: str = field( + metadata={ + "name": "D-PrognosisMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + + flex_order_statuses: List[FlexOrderStatus] = field( + default_factory=list, + metadata={ + "name": "FlexOrderStatus", + "type": "Element", + } + ) + + + +@dataclass(kw_only=True) +class DPrognosis(FlexMessage): + """ + :ivar isp: + :ivar revision: Revision of this message. A sequence number that + must be incremented each time a new revision of a prognosis is + sent. The combination of SenderDomain and PrognosisSequence + should be unique + """ + class Meta: + name = "D-Prognosis" + + isps: List[DPrognosisISP] = field( + default_factory=list, + metadata={ + "name": "ISP", + "type": "Element", + "min_occurs": 1, + } + ) + revision: int = field( + metadata={ + "name": "Revision", + "type": "Attribute", + "required": True, + } + ) + + def __post_init__(self): + validate_list('isps', self.isps, DPrognosisISP, 1) diff --git a/shapeshifter_uftp/uftp/messages/dso_portfolio_query.py b/shapeshifter_uftp/uftp/messages/dso_portfolio_query.py new file mode 100644 index 0000000..a6721cd --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/dso_portfolio_query.py @@ -0,0 +1,164 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import RedispatchBy +from ..validations import validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class DsoPortfolioQueryConnection: + """ + A Connection that is part of the congestion point. + + :ivar entity_address: EntityAddress of the Connection. + :ivar agr_domain: The internet domain of the AGR that represents the + prosumer connected on this Connection, if applicable. + """ + class Meta: + name = "DSOPortfolioQueryConnection" + + entity_address: str = field( + metadata={ + "name": "EntityAddress", + "type": "Attribute", + "required": True, + "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", + } + ) + agr_domain: Optional[str] = field( + default=None, + metadata={ + "name": "AGR-Domain", + "type": "Attribute", + "pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}", + } + ) + + +@dataclass(kw_only=True) +class DsoPortfolioQueryCongestionPoint: + """ + :ivar connection: + :ivar entity_address: EntityAddress of the Connection. + """ + class Meta: + name = "DSOPortfolioQueryCongestionPoint" + + connections: List[DsoPortfolioQueryConnection] = field( + default_factory=list, + metadata={ + "name": "Connection", + "type": "Element", + "min_occurs": 1, + } + ) + entity_address: str = field( + metadata={ + "name": "EntityAddress", + "type": "Attribute", + "required": True, + "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", + } + ) + + def __post_init__(self): + validate_list('connections', self.connections, DsoPortfolioQueryConnection, 1) + + +@dataclass(kw_only=True) +class DsoPortfolioQueryResponse(PayloadMessageResponse): + """ + :ivar congestion_point: + :ivar time_zone: Time zone ID (as per the IANA time zone database, + http://www.iana.org/time-zones, for example: Europe/Amsterdam) + indicating the UTC offset that applies to the Period referenced + in this message. Although the time zone is a market-wide fixed + value, making this assumption explicit in each message is + important for validation purposes, allowing implementations to + reject messages with an errant UTC offset. + :ivar period: The Period for which the AGR requests the portfolio + information. + """ + class Meta: + name = "DSOPortfolioQueryResponse" + + dso_portfolio_query_message_id: str = field( + metadata={ + "name": "DSOPortfolioQueryMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + + congestion_point: Optional[DsoPortfolioQueryCongestionPoint] = field( + default=None, + metadata={ + "name": "CongestionPoint", + "type": "Element", + } + ) + time_zone: str = field( + default=DEFAULT_TIME_ZONE, + metadata={ + "name": "TimeZone", + "type": "Attribute", + "required": True, + "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", + } + ) + period: XmlDate = field( + metadata={ + "name": "Period", + "type": "Attribute", + "required": True, + } + ) + + +@dataclass(kw_only=True) +class DsoPortfolioQuery(PayloadMessage): + """ + :ivar time_zone: Time zone ID (as per the IANA time zone database, + http://www.iana.org/time-zones, for example: Europe/Amsterdam) + indicating the UTC offset that applies to the Period referenced + in this message. Although the time zone is a market-wide fixed + value, making this assumption explicit in each message is + important for validation purposes, allowing implementations to + reject messages with an errant UTC offset. + :ivar period: The Period for which the AGR requests the portfolio + information. + :ivar entity_address: EntityAddress of the CongestionPoint + """ + class Meta: + name = "DSOPortfolioQuery" + + time_zone: str = field( + default=DEFAULT_TIME_ZONE, + metadata={ + "name": "TimeZone", + "type": "Attribute", + "required": True, + "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", + } + ) + period: XmlDate = field( + metadata={ + "name": "Period", + "type": "Attribute", + "required": True, + } + ) + entity_address: str = field( + metadata={ + "name": "EntityAddress", + "type": "Attribute", + "required": True, + "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", + } + ) diff --git a/shapeshifter_uftp/uftp/messages/dso_portfolio_update.py b/shapeshifter_uftp/uftp/messages/dso_portfolio_update.py new file mode 100644 index 0000000..613da5d --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/dso_portfolio_update.py @@ -0,0 +1,178 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import RedispatchBy +from ..validations import validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class DsoPortfolioUpdateConnection: + """ + A connection that the DSO wants the CRO to update. + + :ivar entity_address: EntityAddress of the Connection. + :ivar start_period: The first Period that the Connection is part of + this CongestionPoint. + :ivar end_period: The last Period that the Connection is part of + this CongestionPoint. + """ + class Meta: + name = "DSOPortfolioUpdateConnection" + + entity_address: str = field( + metadata={ + "name": "EntityAddress", + "type": "Attribute", + "required": True, + "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", + } + ) + start_period: XmlDate = field( + metadata={ + "name": "StartPeriod", + "type": "Attribute", + "required": True, + } + ) + end_period: Optional[XmlDate] = field( + default=None, + metadata={ + "name": "EndPeriod", + "type": "Attribute", + } + ) + + +@dataclass(kw_only=True) +class DsoPortfolioUpdateCongestionPoint: + """ + A congestion point that the DSO wants the CRO to update. + + :ivar connection: + :ivar entity_address: EntityAddress of the Connection. + :ivar start_period: The first Period that the Connection is part of + this CongestionPoint. + :ivar end_period: The last Period that the Connection is part of + this CongestionPoint. + :ivar mutex_offers_supported: Indicates whether the DSO accepts + mutual exclusive FlexOffers on this CongestionPoint. + :ivar day_ahead_redispatch_by: Indicates which party is responsible + for day-ahead redispatch. + :ivar intraday_redispatch_by: Indicates which party is responsible + for intraday ahead redispatch, AGR or DSO. If not specified, + there will be no intraday trading on this CongestionPoint. + """ + class Meta: + name = "DSOPortfolioUpdateCongestionPoint" + + connections: List[DsoPortfolioUpdateConnection] = field( + default_factory=list, + metadata={ + "name": "Connection", + "type": "Element", + "min_occurs": 1, + } + ) + entity_address: str = field( + metadata={ + "name": "EntityAddress", + "type": "Attribute", + "required": True, + "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", + } + ) + start_period: XmlDate = field( + metadata={ + "name": "StartPeriod", + "type": "Attribute", + "required": True, + } + ) + end_period: Optional[XmlDate] = field( + default=None, + metadata={ + "name": "EndPeriod", + "type": "Attribute", + } + ) + mutex_offers_supported: bool = field( + metadata={ + "name": "MutexOffersSupported", + "type": "Attribute", + "required": True, + } + ) + day_ahead_redispatch_by: RedispatchBy = field( + metadata={ + "name": "DayAheadRedispatchBy", + "type": "Attribute", + "required": True, + } + ) + intraday_redispatch_by: Optional[RedispatchBy] = field( + default=None, + metadata={ + "name": "IntradayRedispatchBy", + "type": "Attribute", + } + ) + + def __post_init__(self): + validate_list('connections', self.connections, DsoPortfolioUpdateConnection, 1) + + +@dataclass(kw_only=True) +class DsoPortfolioUpdateResponse(PayloadMessageResponse): + class Meta: + name = "DSOPortfolioUpdateResponse" + + dso_portfolio_update_message_id: str = field( + metadata={ + "name": "DSOPortfolioUpdateResponseMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + + +@dataclass(kw_only=True) +class DsoPortfolioUpdate(PayloadMessage): + """ + :ivar congestion_point: + :ivar time_zone: Time zone ID (as per the IANA time zone database, + http://www.iana.org/time-zones, for example: Europe/Amsterdam) + indicating the UTC offset that applies to the Period referenced + in this message. Although the time zone is a market-wide fixed + value, making this assumption explicit in each message is + important for validation purposes, allowing implementations to + reject messages with an errant UTC offset. + """ + class Meta: + name = "DSOPortfolioUpdate" + + congestion_points: List[DsoPortfolioUpdateCongestionPoint] = field( + default_factory=list, + metadata={ + "name": "CongestionPoint", + "type": "Element", + "min_occurs": 1, + } + ) + time_zone: str = field( + default=DEFAULT_TIME_ZONE, + metadata={ + "name": "TimeZone", + "type": "Attribute", + "required": True, + "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", + } + ) + + def __post_init__(self): + validate_list('congestion_points', self.congestion_points, DsoPortfolioUpdateCongestionPoint, 1) diff --git a/shapeshifter_uftp/uftp/messages/flex_message.py b/shapeshifter_uftp/uftp/messages/flex_message.py new file mode 100644 index 0000000..ab528cb --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/flex_message.py @@ -0,0 +1,61 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class FlexMessage(PayloadMessage): + """ + :ivar isp_duration: ISO 8601 time interval (minutes only, for + example PT15M) indicating the duration of the ISPs referenced in + this message. Although the ISP length is a market-wide fixed + value, making this assumption explicit in each message is + important for validation purposes, allowing implementations to + reject messages with an errant ISP duration. + :ivar time_zone: Time zone ID (as per the IANA time zone database, + http://www.iana.org/time-zones, for example: Europe/Amsterdam) + indicating the UTC offset that applies to the Period referenced + in this message. Although the time zone is a market-wide fixed + value, making this assumption explicit in each message is + important for validation purposes, allowing implementations to + reject messages with an errant UTC offset. + :ivar period: Day (in yyyy-mm-dd format) the ISPs referenced in this + Flex* message belong to. + :ivar congestion_point: Entity Address of the Congestion Point this + D-Prognosis applies to. + """ + isp_duration: XmlDuration = field( + metadata={ + "name": "ISP-Duration", + "type": "Attribute", + "required": True, + } + ) + time_zone: str = field( + default=DEFAULT_TIME_ZONE, + metadata={ + "name": "TimeZone", + "type": "Attribute", + "required": True, + "pattern": r"(Africa|America|Australia|Europe|Pacific)/[a-zA-Z0-9_/]{3,}", + } + ) + period: XmlDate = field( + metadata={ + "name": "Period", + "type": "Attribute", + "required": True, + } + ) + congestion_point: str = field( + metadata={ + "name": "CongestionPoint", + "type": "Attribute", + "required": True, + "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", + } + ) diff --git a/shapeshifter_uftp/uftp/messages/flex_offer.py b/shapeshifter_uftp/uftp/messages/flex_offer.py new file mode 100644 index 0000000..96b5ad9 --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/flex_offer.py @@ -0,0 +1,194 @@ +from dataclasses import dataclass, field +from decimal import Decimal +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import RedispatchBy +from ..validations import validate_decimal, validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class FlexOfferOptionISP: + """ + :ivar power: Power specified for this ISP in Watts. Also see the + important notes about the sign of this attribute in the main + documentation entry for the ISP element. + :ivar start: Number of the first ISPs this element refers to. The + first ISP of a day has number 1. + :ivar duration: The number of the ISPs this element represents. + Optional, default value is 1. + """ + class Meta: + name = "FlexOfferOptionISP" + + power: int = field( + metadata={ + "name": "Power", + "type": "Attribute", + "required": True, + } + ) + start: int = field( + metadata={ + "name": "Start", + "type": "Attribute", + "required": True, + } + ) + duration: int = field( + default=1, + metadata={ + "name": "Duration", + "type": "Attribute", + } + ) + + +@dataclass(kw_only=True) +class FlexOfferOption: + """ + :ivar isp: + :ivar option_reference: The identification of this option. + :ivar price: The asking price for the flexibility offered in this + option. + :ivar min_activation_factor: The minimal activation factor for this + OfferOption. An AGR may choose to include MinActivationFactor in + FlexOffers even if the DSO is not interested in partial + activation. In that case the DSO will simply use an + ActivationFactor of 1.00 in every FlexOrder. + """ + isps: List[FlexOfferOptionISP] = field( + default_factory=list, + metadata={ + "name": "ISP", + "type": "Element", + "min_occurs": 1, + } + ) + option_reference: str = field( + metadata={ + "name": "OptionReference", + "type": "Attribute", + "required": True, + } + ) + price: Decimal = field( + metadata={ + "name": "Price", + "type": "Attribute", + "required": True, + "fraction_digits": 4, + } + ) + min_activation_factor: Decimal = field( + default=Decimal("1.00"), + metadata={ + "name": "MinActivationFactor", + "type": "Attribute", + "min_inclusive": Decimal("0.01"), + "max_inclusive": Decimal("1.00"), + "fraction_digits": 2, + } + ) + + def __post_init__(self): + validate_list('isps', self.isps, FlexOfferOptionISP, 1) + self.price = validate_decimal('price', self.price, 4) + self.min_activation_factor = validate_decimal('min_activation_factor', self.min_activation_factor, 2) + + +@dataclass(kw_only=True) +class FlexOfferResponse(PayloadMessageResponse): + flex_offer_message_id: str = field( + metadata={ + "name": "FlexOfferMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + + +@dataclass(kw_only=True) +class FlexOffer(FlexMessage): + """ + :ivar offer_option: + :ivar expiration_date_time: Date and time, including the time zone + (ISO 8601 formatted as per http://www.w3.org/TR/NOTE-datetime) + until which the FlexOffer is valid. + :ivar flex_request_message_id: MessageID of the FlexRequest message + this request is based on. Mandatory if and only if solicited. + :ivar contract_id: Reference to the concerning contract, if + applicable. The contract may be either bilateral or commoditized + market contract. + :ivar d_prognosis_message_id: MessageID of the D-Prognosis this + request is based on, if it has been agreed that the baseline is + based on D-prognoses. + :ivar baseline_reference: Identification of the baseline prognosis, + if another baseline methodology is used than based on + D-prognoses + :ivar currency: ISO 4217 code indicating the currency that applies + to the price of the FlexOffer. + """ + offer_options: List[FlexOfferOption] = field( + default_factory=list, + metadata={ + "name": "OfferOption", + "type": "Element", + "min_occurs": 1, + } + ) + expiration_date_time: str = field( + metadata={ + "name": "ExpirationDateTime", + "type": "Attribute", + "required": True, + "pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)", + } + ) + flex_request_message_id: Optional[str] = field( + default=None, + metadata={ + "name": "FlexRequestMessageID", + "type": "Attribute", + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + contract_id: Optional[str] = field( + default=None, + metadata={ + "name": "ContractID", + "type": "Attribute", + } + ) + d_prognosis_message_id: Optional[str] = field( + default=None, + metadata={ + "name": "D-PrognosisMessageID", + "type": "Attribute", + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + baseline_reference: Optional[str] = field( + default=None, + metadata={ + "name": "BaselineReference", + "type": "Attribute", + } + ) + currency: str = field( + default="EUR", + metadata={ + "name": "Currency", + "type": "Attribute", + "required": True, + "pattern": r"[A-Z]{3}", + } + ) + + def __post_init__(self): + validate_list('offer_options', self.offer_options, FlexOfferOption, 1) diff --git a/shapeshifter_uftp/uftp/messages/flex_offer_revocation.py b/shapeshifter_uftp/uftp/messages/flex_offer_revocation.py new file mode 100644 index 0000000..6f050e1 --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/flex_offer_revocation.py @@ -0,0 +1,39 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import RedispatchBy +from ..validations import validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class FlexOfferRevocationResponse(PayloadMessageResponse): + flex_offer_revocation_message_id: str = field( + metadata={ + "name": "FlexOfferRevocationMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + + +@dataclass(kw_only=True) +class FlexOfferRevocation(PayloadMessage): + """ + :ivar flex_offer_message_id: MessageID of the FlexOffer message that + is being revoked: this FlexOffer must have been accepted + previously. + """ + flex_offer_message_id: str = field( + metadata={ + "name": "FlexOfferMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) diff --git a/shapeshifter_uftp/uftp/messages/flex_order.py b/shapeshifter_uftp/uftp/messages/flex_order.py new file mode 100644 index 0000000..362ff19 --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/flex_order.py @@ -0,0 +1,174 @@ +from dataclasses import dataclass, field +from decimal import Decimal +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import RedispatchBy +from ..validations import validate_decimal, validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class FlexOrderISP: + """ + :ivar power: Power specified for this ISP in Watts. Also see the + important notes about the sign of this attribute in the main + documentation entry for the ISP element. + :ivar start: Number of the first ISPs this element refers to. The + first ISP of a day has number 1. + :ivar duration: The number of the ISPs this element represents. + Optional, default value is 1. + """ + class Meta: + name = "FlexOrderISP" + + power: int = field( + metadata={ + "name": "Power", + "type": "Attribute", + "required": True, + } + ) + start: int = field( + metadata={ + "name": "Start", + "type": "Attribute", + "required": True, + } + ) + duration: int = field( + default=1, + metadata={ + "name": "Duration", + "type": "Attribute", + } + ) + + +@dataclass(kw_only=True) +class FlexOrderResponse(PayloadMessageResponse): + flex_order_message_id: str = field( + metadata={ + "name": "FlexOrderMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + +@dataclass(kw_only=True) +class FlexOrder(FlexMessage): + """ + :ivar isp: + :ivar flex_offer_message_id: MessageID of the FlexOffer message this + order is based on. + :ivar contract_id: Reference to the concerning bilateral contract, + if applicable. + :ivar d_prognosis_message_id: MessageID of the D-Prognosis this + request is based on, if it has been agreed that the baseline is + based on D-prognoses. + :ivar baseline_reference: Identification of the baseline prognosis, + if another baseline methodology is used than based on + D-prognoses + :ivar price: The price for the flexibility ordered. Usually, the + price should match the price of the related FlexOffer. + :ivar currency: ISO 4217 code indicating the currency that applies + to the price of the FlexOffer. + :ivar order_reference: Order number assigned by the DSO originating + the FlexOrder. To be stored by the AGR and used in the + settlement phase. + :ivar option_reference: The OptionReference from the OfferOption + chosen from the FlexOffer. + :ivar activation_factor: The activation factor for this OfferOption. + The ActivationFactor must be greater than or equal to the + MinActivationFactor in the OfferOption chosen from the + FlexOffer. + """ + isps: List[FlexOrderISP] = field( + default_factory=list, + metadata={ + "name": "ISP", + "type": "Element", + "min_occurs": 1, + } + ) + flex_offer_message_id: str = field( + metadata={ + "name": "FlexOfferMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + contract_id: Optional[str] = field( + default=None, + metadata={ + "name": "ContractID", + "type": "Attribute", + } + ) + d_prognosis_message_id: Optional[str] = field( + default=None, + metadata={ + "name": "D-PrognosisMessageID", + "type": "Attribute", + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + baseline_reference: Optional[str] = field( + default=None, + metadata={ + "name": "BaselineReference", + "type": "Attribute", + } + ) + price: Decimal = field( + metadata={ + "name": "Price", + "type": "Attribute", + "required": True, + "fraction_digits": 4, + } + ) + currency: str = field( + metadata={ + "name": "Currency", + "type": "Attribute", + "required": True, + "pattern": r"[A-Z]{3}", + } + ) + order_reference: str = field( + metadata={ + "name": "OrderReference", + "type": "Attribute", + "required": True, + } + ) + option_reference: Optional[str] = field( + default=None, + metadata={ + "name": "OptionReference", + "type": "Attribute", + } + ) + activation_factor: Decimal = field( + default=Decimal("1.00"), + metadata={ + "name": "ActivationFactor", + "type": "Attribute", + "min_inclusive": Decimal("0.01"), + "max_inclusive": Decimal("1.00"), + "fraction_digits": 2, + } + ) + + def __post_init__(self): + validate_list("isps", self.isps, FlexOrderISP, 1) + self.price = validate_decimal("price", self.price, 4) + self.activation_factor = validate_decimal( + "activation_factor", self.activation_factor, 2 + ) diff --git a/shapeshifter_uftp/uftp/messages/flex_request.py b/shapeshifter_uftp/uftp/messages/flex_request.py new file mode 100644 index 0000000..39ef253 --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/flex_request.py @@ -0,0 +1,137 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import AvailableRequested +from ..validations import validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class FlexRequestISP: + """ + :ivar disposition: + :ivar min_power: Power specified for this ISP in Watts. Also see the + important notes about the sign of this attribute in the main + documentation entry for the ISP element. + :ivar max_power: Power specified for this ISP in Watts. Also see the + important notes about the sign of this attribute in the main + documentation entry for the ISP element. + :ivar start: Number of the first ISPs this element refers to. The + first ISP of a day has number 1. + :ivar duration: The number of the ISPs this element represents. + Optional, default value is 1. + """ + class Meta: + name = "FlexRequestISP" + + disposition: Optional[AvailableRequested] = field( + default=None, + metadata={ + "name": "Disposition", + "type": "Attribute", + } + ) + min_power: int = field( + metadata={ + "name": "MinPower", + "type": "Attribute", + "required": True, + } + ) + max_power: int = field( + metadata={ + "name": "MaxPower", + "type": "Attribute", + "required": True, + } + ) + start: int = field( + metadata={ + "name": "Start", + "type": "Attribute", + "required": True, + } + ) + duration: int = field( + default=1, + metadata={ + "name": "Duration", + "type": "Attribute", + } + ) + + +@dataclass(kw_only=True) +class FlexRequestResponse(PayloadMessageResponse): + + flex_request_message_id: str = field( + metadata={ + "name": "FlexRequestMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + +@dataclass(kw_only=True) +class FlexRequest(FlexMessage): + """ + :ivar isp: + :ivar revision: Revision of this message, a sequence number that + must be incremented each time a new revision of a FlexRequest + message is sent. + :ivar expiration_date_time: Date and time, including the time zone + (ISO 8601 formatted as per http://www.w3.org/TR/NOTE-datetime) + until which the FlexRequest message is valid. + :ivar contract_id: Reference to the concerning contract, if + applicable. The contract may be either bilateral or commoditized + market contract. Each contract may specify multiple service- + types. + :ivar service_type: Service type for this request, the service type + determines response characteristics such as latency or asset + participation type. + """ + isps: List[FlexRequestISP] = field( + default_factory=list, + metadata={ + "name": "ISP", + "type": "Element", + "min_occurs": 1, + } + ) + revision: int = field( + metadata={ + "name": "Revision", + "type": "Attribute", + "required": True, + } + ) + expiration_date_time: str = field( + metadata={ + "name": "ExpirationDateTime", + "type": "Attribute", + "required": True, + "pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)", + } + ) + contract_id: Optional[str] = field( + default=None, + metadata={ + "name": "ContractID", + "type": "Attribute", + } + ) + service_type: Optional[str] = field( + default=None, + metadata={ + "name": "ServiceType", + "type": "Attribute", + } + ) + + def __post_init__(self): + validate_list('isps', self.isps, FlexRequestISP, 1) diff --git a/shapeshifter_uftp/uftp/messages/flex_reservation_update.py b/shapeshifter_uftp/uftp/messages/flex_reservation_update.py new file mode 100644 index 0000000..958f096 --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/flex_reservation_update.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import RedispatchBy +from ..validations import validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class FlexReservationUpdateISP: + """ + :ivar power: Remaining reserved power specified for this ISP in + Watts. + :ivar start: Number of the first ISPs this element refers to. The + first ISP of a day has number 1. + :ivar duration: The number of the ISPs this element represents. + Optional, default value is 1. + """ + class Meta: + name = "FlexReservationUpdateISP" + + power: int = field( + metadata={ + "name": "Power", + "type": "Attribute", + "required": True, + } + ) + start: int = field( + metadata={ + "name": "Start", + "type": "Attribute", + "required": True, + } + ) + duration: int = field( + default=1, + metadata={ + "name": "Duration", + "type": "Attribute", + } + ) + + +@dataclass(kw_only=True) +class FlexReservationUpdateResponse(PayloadMessageResponse): + + flex_reservation_update_message_id: str = field( + metadata={ + "name": "FlexReservationUpdateMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + + +@dataclass(kw_only=True) +class FlexReservationUpdate(FlexMessage): + """ + :ivar isp: + :ivar contract_id: Reference to the bilateral contract in question. + :ivar reference: Message reference, assigned by the DSO originating + the FlexReservationUpdate. + """ + isps: List[FlexReservationUpdateISP] = field( + default_factory=list, + metadata={ + "name": "ISP", + "type": "Element", + "min_occurs": 1, + } + ) + contract_id: str = field( + metadata={ + "name": "ContractID", + "type": "Attribute", + "required": True, + } + ) + reference: str = field( + metadata={ + "name": "Reference", + "type": "Attribute", + "required": True, + } + ) + + def __post_init__(self): + validate_list('isps', self.isps, FlexReservationUpdateISP, 1) diff --git a/shapeshifter_uftp/uftp/messages/flex_settlement.py b/shapeshifter_uftp/uftp/messages/flex_settlement.py new file mode 100644 index 0000000..dff7980 --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/flex_settlement.py @@ -0,0 +1,449 @@ +from dataclasses import dataclass, field +from decimal import Decimal +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import AcceptedDisputed, RedispatchBy +from ..validations import validate_decimal, validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class ContractSettlementISP: + """ + :ivar start: Number of the first ISPs this element refers to. The + first ISP of a day has number 1. + :ivar duration: The number of the ISPs this element represents. + Optional, default value is 1. + :ivar reserved_power: Amount of flex power that has been reserved + (and not released using a FlexReservationUpdate message). + :ivar requested_power: Amount of flex power that has been both + reserved in advance and has been requested using a FlexRequest + (i.e. the lowest amount of flex power for this ISP). If there + was no FlexRequest, this field is omitted. + :ivar available_power: Amount of flex power that is considered + available based on the FlexRequest in question. In case + RequestedPower=0, AvailablePower is defined so that the offered + power is allowed to be between 0 and AvailablePower in terms of + compliancy (see Appendix 'Rationale for information exchange in + flexibility request' for details). In case RequestedPower ≠0, + AvailablePower is defined so that the offered power is allowed + to exceed the amount of requested power up to AvailablePower. If + this is relevant for settlement, the DSO can include this field. + :ivar offered_power: Amount of flex power that has been reserved in + advance, requested using a FlexRequest and covered in an offer + from the AGR. If there was no offer, this field is omitted. If + there were multiple offers, only the one is considered that is + most compliant . + :ivar ordered_power: Amount of flex power that has been ordered + using a FlexOrder message that was based on a FlexOffer, both + linked to this contract. If there was no order, this field is + omitted. + """ + class Meta: + name = "ContractSettlementISP" + + start: int = field( + metadata={ + "name": "Start", + "type": "Attribute", + "required": True, + } + ) + duration: int = field( + default=1, + metadata={ + "name": "Duration", + "type": "Attribute", + } + ) + reserved_power: int = field( + metadata={ + "name": "ReservedPower", + "type": "Attribute", + "required": True, + } + ) + requested_power: Optional[int] = field( + default=None, + metadata={ + "name": "RequestedPower", + "type": "Attribute", + } + ) + available_power: Optional[int] = field( + default=None, + metadata={ + "name": "AvailablePower", + "type": "Attribute", + } + ) + offered_power: Optional[int] = field( + default=None, + metadata={ + "name": "OfferedPower", + "type": "Attribute", + } + ) + ordered_power: Optional[int] = field( + default=None, + metadata={ + "name": "OrderedPower", + "type": "Attribute", + } + ) + + +@dataclass(kw_only=True) +class ContractSettlementPeriod: + """ + :ivar isp: + :ivar period: Period the being settled. + """ + isps: List[ContractSettlementISP] = field( + default_factory=list, + metadata={ + "name": "ISP", + "type": "Element", + "min_occurs": 1, + } + ) + period: XmlDate = field( + metadata={ + "name": "Period", + "type": "Attribute", + "required": True, + } + ) + + +@dataclass(kw_only=True) +class ContractSettlement: + """ + :ivar period: + :ivar contract_id: Reference to the concerning bilateral contract. + """ + periods: List[ContractSettlementPeriod] = field( + default_factory=list, + metadata={ + "name": "Period", + "type": "Element", + "min_occurs": 1, + } + ) + contract_id: Optional[str] = field( + default=None, + metadata={ + "name": "ContractID", + "type": "Attribute", + } + ) + + def __post_init__(self): + validate_list('periods', self.periods, ContractSettlementPeriod, 1) + + +@dataclass(kw_only=True) +class FlexOrderSettlementStatus: + """ + :ivar order_reference: Order reference assigned by the DSO when + originating the FlexOrder. + :ivar disposition: Indication whether the AGR accepts the order + settlement details provided by the DSO (and will invoice + accordingly), or disputes these details. + :ivar dispute_reason: In case the order settlement was disputed, + this attribute must contain a human-readable description of the + reason. + """ + order_reference: Optional[str] = field( + default=None, + metadata={ + "name": "OrderReference", + "type": "Attribute", + } + ) + disposition: AcceptedDisputed = field( + metadata={ + "name": "Disposition", + "type": "Attribute", + "required": True, + } + ) + dispute_reason: Optional[str] = field( + default=None, + metadata={ + "name": "DisputeReason", + "type": "Attribute", + } + ) + +@dataclass(kw_only=True) +class FlexOrderSettlementISP: + """ + :ivar start: Number of the first ISPs this element refers to. The + first ISP of a day has number 1. + :ivar duration: The number of the ISPs this element represents. + Optional, default value is 1. + :ivar baseline_power: Power originally forecast (as per the + referenced baseline) for this ISP in Watts. + :ivar ordered_flex_power: Amount of flex power ordered (as per the + referenced FlexOrder message) for this ISP in Watts. + :ivar actual_power: Actual amount of power for this ISP in Watts, as + measured/determined by the DSO and allocated to the AGR. + :ivar delivered_flex_power: Actual amount of flex power delivered + for this ISP in Watts, as determined by the DSO. + :ivar power_deficiency: Amount of flex power sold but not delivered + for this ISP in Watts, as determined by the DSO. + """ + class Meta: + name = "FlexOrderSettlementISP" + + start: int = field( + metadata={ + "name": "Start", + "type": "Attribute", + "required": True, + } + ) + duration: int = field( + default=1, + metadata={ + "name": "Duration", + "type": "Attribute", + } + ) + baseline_power: int = field( + metadata={ + "name": "BaselinePower", + "type": "Attribute", + "required": True, + } + ) + ordered_flex_power: int = field( + metadata={ + "name": "OrderedFlexPower", + "type": "Attribute", + "required": True, + } + ) + actual_power: int = field( + metadata={ + "name": "ActualPower", + "type": "Attribute", + "required": True, + } + ) + delivered_flex_power: int = field( + metadata={ + "name": "DeliveredFlexPower", + "type": "Attribute", + "required": True, + } + ) + power_deficiency: int = field( + default=0, + metadata={ + "name": "PowerDeficiency", + "type": "Attribute", + } + ) + + +@dataclass(kw_only=True) +class FlexOrderSettlement: + """ + :ivar isp: + :ivar order_reference: Order reference assigned by the DSO when + originating the FlexOrder. + :ivar period: + :ivar contract_id: Reference to the concerning bilateral contract, + if it is linked to it + :ivar d_prognosis_message_id: MessageID of the Prognosis message + (more specifically: the D-Prognosis) the FlexOrder is based on, + if it has been agreed that the baseline is based on D-prognoses. + :ivar baseline_reference: Identification of the baseline prognosis, + if another baseline methodology is used than based on + D-prognoses. + :ivar congestion_point: Entity Address of the Congestion Point the + FlexOrder applies to. + :ivar price: The price accepted for supplying the ordered amount of + flexibility as per the referenced FlexOrder messages. + :ivar penalty: Penalty due a non-zero PowerDeficiency + :ivar net_settlement: Net settlement amount for this Period: Price + minus Penalty. + """ + isps: List[FlexOrderSettlementISP] = field( + default_factory=list, + metadata={ + "name": "ISP", + "type": "Element", + "min_occurs": 1, + } + ) + order_reference: Optional[str] = field( + default=None, + metadata={ + "name": "OrderReference", + "type": "Attribute", + } + ) + period: XmlDate = field( + metadata={ + "name": "Period", + "type": "Attribute", + "required": True, + } + ) + contract_id: Optional[str] = field( + default=None, + metadata={ + "name": "ContractID", + "type": "Attribute", + } + ) + d_prognosis_message_id: Optional[str] = field( + default=None, + metadata={ + "name": "D-PrognosisMessageID", + "type": "Attribute", + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + baseline_reference: Optional[str] = field( + default=None, + metadata={ + "name": "BaselineReference", + "type": "Attribute", + } + ) + congestion_point: str = field( + metadata={ + "name": "CongestionPoint", + "type": "Attribute", + "required": True, + "pattern": r"(ea1\.[0-9]{4}-[0-9]{2}\..{1,244}:.{1,244}|ean\.[0-9]{12,34})", + } + ) + price: Decimal = field( + metadata={ + "name": "Price", + "type": "Attribute", + "required": True, + "fraction_digits": 4, + } + ) + penalty: Decimal = field( + default=Decimal("0"), + metadata={ + "name": "Penalty", + "type": "Attribute", + "fraction_digits": 4, + } + ) + net_settlement: Decimal = field( + metadata={ + "name": "NetSettlement", + "type": "Attribute", + "required": True, + "fraction_digits": 4, + } + ) + + def __post_init__(self): + validate_list('isps', self.isps, FlexOrderSettlementISP, 1) + self.price = validate_decimal('price', self.price, 4) + self.penalty = validate_decimal('penalty', self.penalty, 4) + self.net_settlement = validate_decimal('net_settlement', self.net_settlement, 4) + + +@dataclass(kw_only=True) +class FlexSettlementResponse(PayloadMessageResponse): + flex_settlement_message_id: str = field( + metadata={ + "name": "FlexSettlementMessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + + flex_order_settlement_statuses: List[FlexOrderSettlementStatus] = field( + default_factory=list, + metadata={ + "name": "FlexOrderSettlementStatus", + "type": "Element", + "min_occurs": 1, + } + ) + + def __post_init__(self): + validate_list( + "flex_order_settlement_statuses", + self.flex_order_settlement_statuses, + FlexOrderSettlementStatus, + 1, + ) + + +@dataclass(kw_only=True) +class FlexSettlement(PayloadMessageResponse): + """ + :ivar flex_order_settlement: + :ivar contract_settlement: + :ivar period_start: First Period of the settlement period this + message applies to. + :ivar period_end: Last Period of the settlement period this message + applies to. + :ivar currency: ISO 4217 code indicating the currency that applies + to all amounts (flex price, penalty and net settlement) in this + message. + """ + flex_order_settlements: List[FlexOrderSettlement] = field( + default_factory=list, + metadata={ + "name": "FlexOrderSettlement", + "type": "Element", + "min_occurs": 1, + } + ) + contract_settlements: List[ContractSettlement] = field( + default_factory=list, + metadata={ + "name": "ContractSettlement", + "type": "Element", + "min_occurs": 1, + } + ) + period_start: XmlDate = field( + metadata={ + "name": "PeriodStart", + "type": "Attribute", + "required": True, + } + ) + period_end: XmlDate = field( + metadata={ + "name": "PeriodEnd", + "type": "Attribute", + "required": True, + } + ) + currency: str = field( + metadata={ + "name": "Currency", + "type": "Attribute", + "required": True, + "pattern": r"[A-Z]{3}", + } + ) + + def __post_init__(self): + validate_list( + "flex_order_settlements", self.flex_order_settlements, FlexOrderSettlement, 1 + ) + validate_list( + "contract_settlements", self.contract_settlements, ContractSettlement, 1 + ) diff --git a/shapeshifter_uftp/uftp/metering.py b/shapeshifter_uftp/uftp/messages/metering.py similarity index 96% rename from shapeshifter_uftp/uftp/metering.py rename to shapeshifter_uftp/uftp/messages/metering.py index f32fadc..2078cbb 100644 --- a/shapeshifter_uftp/uftp/metering.py +++ b/shapeshifter_uftp/uftp/messages/metering.py @@ -5,8 +5,11 @@ from xsdata.models.datatype import XmlDate, XmlDuration -from .common import PayloadMessage, PayloadMessageResponse -from .validations import validate_list +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import RedispatchBy +from ..validations import validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse # pylint: disable=missing-class-docstring,duplicate-code diff --git a/shapeshifter_uftp/uftp/messages/payload_message.py b/shapeshifter_uftp/uftp/messages/payload_message.py new file mode 100644 index 0000000..6209819 --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/payload_message.py @@ -0,0 +1,117 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..enums import AcceptedRejected + + +@dataclass(kw_only=True) +class PayloadMessage: + """ + :ivar version: Version of the Shapeshifter specification used by the + USEF participant sending this message. + :ivar sender_domain: The Internet domain of the USEF participant + sending this message. When receiving a message, its value should + match the value specified in the SignedMessage wrapper: + otherwise, the message must be rejected as invalid. When + replying to this message, this attribute is used to look up the + USEF endpoint the reply message should be delivered to. + :ivar recipient_domain: Internet domain of the participant this + message is intended for. When sending a message, this attribute, + combined with the RecipientRole, is used to look up the USEF + endpoint the message should be delivered to. + :ivar time_stamp: Date and time this message was created, including + the time zone (ISO 8601 formatted as per + http://www.w3.org/TR/NOTE-datetime). + :ivar message_id: Unique identifier (UUID/GUID as per IETF RFC 4122) + for this message, to be generated when composing each message. + :ivar conversation_id: Unique identifier (UUID/GUID as per IETF RFC + 4122) used to correlate responses with requests, to be generated + when composing the first message in a conversation and + subsequently copied from the original message to each reply + message. + """ + + version: Optional[str] = field( + default="3.0.0", + metadata={ + "name": "Version", + "type": "Attribute", + "required": True, + "pattern": r"(\d+\.\d+\.\d+)", + } + ) + sender_domain: Optional[str] = field( + default=None, + metadata={ + "name": "SenderDomain", + "type": "Attribute", + "required": True, + "pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}", + } + ) + recipient_domain: Optional[str] = field( + default=None, + metadata={ + "name": "RecipientDomain", + "type": "Attribute", + "required": True, + "pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}", + } + ) + time_stamp: Optional[str] = field( + default=None, + metadata={ + "name": "TimeStamp", + "type": "Attribute", + "required": True, + "pattern": r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{0,9})?([+-]\d{2}:\d{2}|Z)", + } + ) + message_id: Optional[str] = field( + default=None, + metadata={ + "name": "MessageID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + conversation_id: Optional[str] = field( + default=None, + metadata={ + "name": "ConversationID", + "type": "Attribute", + "required": True, + "pattern": r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", + } + ) + + +@dataclass(kw_only=True) +class PayloadMessageResponse(PayloadMessage): + """ + :ivar reference_message_id: MessageID of the message that has just + been accepted or rejected. + :ivar result: Indication whether the query was executed successfully + or failed. + :ivar rejection_reason: In case the query failed, this attribute + must contain a human-readable description of the failure reason. + """ + + result: Optional[AcceptedRejected] = field( + default=AcceptedRejected.ACCEPTED, + metadata={ + "name": "Result", + "type": "Attribute", + "required": True, + } + ) + rejection_reason: Optional[str] = field( + default=None, + metadata={ + "name": "RejectionReason", + "type": "Attribute", + }, + ) diff --git a/shapeshifter_uftp/uftp/messages/signed_message.py b/shapeshifter_uftp/uftp/messages/signed_message.py new file mode 100644 index 0000000..0b8910c --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/signed_message.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..enums import UsefRole + + +@dataclass(kw_only=True) +class SignedMessage: + """The SignedMessage element represents the secure wrapper used to submit USEF + XML messages from the local message queue to the message queue of a remote + participant. + + It contains minimal metadata (which is distinct from the common + metadata used for all other messages), allowing the recipient to + look up the sender's cryptographic scheme and public keys, and the + actual XML message, as transformed (signed/sealed) using that + cryptographic scheme. + + :ivar sender_domain: The Internet domain of the USEF participant + sending this message. Upon receiving a message, the recipient + should validate that its value matches the corresponding + attribute value specified in the inner XML message, once un- + sealed: if not, the message must be rejected as invalid. + :ivar sender_role: The USEF role of the participant sending this + message: AGR, BRP, CRO, DSO or MDC. Receive-time validation + should take place as described for the SenderDomain attribute + above. + :ivar body: The Base-64 encoded inner XML message contained in this + wrapper, as transformed (signed/sealed) using the sender's + cryptographic scheme. The recipient can determine which scheme + applies using a DNS or configuration file lookup, based on the + combination of SenderDomain and SenderRole. + """ + + sender_domain: str = field( + metadata={ + "name": "SenderDomain", + "type": "Attribute", + "required": True, + "pattern": r"([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}", + } + ) + sender_role: UsefRole = field( + metadata={ + "name": "SenderRole", + "type": "Attribute", + "required": True, + } + ) + body: bytes = field( + metadata={ + "name": "Body", + "type": "Attribute", + "required": True, + "format": "base64", + } + ) diff --git a/shapeshifter_uftp/uftp/messages/test_message.py b/shapeshifter_uftp/uftp/messages/test_message.py new file mode 100644 index 0000000..dc60683 --- /dev/null +++ b/shapeshifter_uftp/uftp/messages/test_message.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from xsdata.models.datatype import XmlDate, XmlDuration + +from ..defaults import DEFAULT_TIME_ZONE +from ..enums import UsefRole +from ..validations import validate_list +from .flex_message import FlexMessage +from .payload_message import PayloadMessage, PayloadMessageResponse + + +@dataclass(kw_only=True) +class TestMessage(PayloadMessage): + __test__ = False # Tell pytest to ignore this class + + +@dataclass(kw_only=True) +class TestMessageResponse(PayloadMessageResponse): + __test__ = False # Tell pytest to ignore this class diff --git a/test/helpers/messages.py b/test/helpers/messages.py index b5e60b9..f75cfb9 100644 --- a/test/helpers/messages.py +++ b/test/helpers/messages.py @@ -1,8 +1,10 @@ -from uuid import uuid4 from datetime import datetime, timezone -from shapeshifter_uftp.uftp import * +from uuid import uuid4 + from xsdata.models.datatype import XmlDate +from shapeshifter_uftp.uftp import * + default_args = { "version": "3.0.0", "sender_domain": "agr.dev", diff --git a/test/helpers/services.py b/test/helpers/services.py index dea0598..a4e75cf 100644 --- a/test/helpers/services.py +++ b/test/helpers/services.py @@ -1,12 +1,17 @@ -from shapeshifter_uftp import ShapeshifterAgrService, ShapeshifterCroService, ShapeshifterDsoService +import itertools +from base64 import b64decode, b64encode from concurrent.futures import Future -from base64 import b64encode, b64decode + from nacl.bindings import crypto_sign_keypair + +from shapeshifter_uftp import ( + ShapeshifterAgrService, + ShapeshifterCroService, + ShapeshifterDsoService, +) from shapeshifter_uftp.logging import logger -import itertools from shapeshifter_uftp.service.base_service import snake_case - AGR_DOMAIN = "agr.dev" CRO_DOMAIN = "cro.dev" DSO_DOMAIN = "dso.dev" @@ -52,7 +57,7 @@ def __init__(self): self.request_futures = { f"{stage}_{name}": Future() for stage, name in itertools.product( - ["pre_process", "process"], + ["process"], [ name for name in [ @@ -63,81 +68,33 @@ def __init__(self): ) } - self.response_futures = { - name: Future() - for name in [ - f"pre_process_{snake_case(message.__name__)}" - for message in self.acceptable_messages - ] - } - - def pre_process_flex_request(self, message): - self.request_futures["pre_process_flex_request"].set_result(message) - return self.response_futures["pre_process_flex_request"].result() - def process_flex_request(self, message): self.request_futures["process_flex_request"].set_result(message) - def pre_process_flex_order(self, message): - self.request_futures["pre_process_flex_order"].set_result(message) - return self.response_futures["pre_process_flex_order"].result() - def process_flex_order(self, message): self.request_futures["process_flex_order"].set_result(message) - def pre_process_flex_reservation_update(self, message): - self.request_futures["pre_process_flex_reservation_update"].set_result(message) - return self.response_futures["pre_process_flex_reservation_update"].result() - def process_flex_reservation_update(self, message): self.request_futures["process_flex_reservation_update"].set_result(message) - def pre_process_flex_settlement(self, message): - self.request_futures["pre_process_flex_settlement"].set_result(message) - return self.response_futures["pre_process_flex_settlement"].result() - def process_flex_settlement(self, message): self.request_futures["process_flex_settlement"].set_result(message) - def pre_process_flex_offer_revocation_response(self, message): - self.request_futures["pre_process_flex_offer_revocation_response"].set_result(message) - return self.response_futures["pre_process_flex_offer_revocation_response"].result() - def process_flex_offer_revocation_response(self, message): self.request_futures["process_flex_offer_revocation_response"].set_result(message) - def pre_process_agr_portfolio_query_response(self, message): - self.request_futures["pre_process_agr_portfolio_query_response"].set_result(message) - return self.response_futures["pre_process_agr_portfolio_query_response"].result() - def process_agr_portfolio_query_response(self, message): self.request_futures["process_agr_portfolio_query_response"].set_result(message) - def pre_process_agr_portfolio_update_response(self, message): - self.request_futures["pre_process_agr_portfolio_update_response"].set_result(message) - return self.response_futures["pre_process_agr_portfolio_update_response"].result() - def process_agr_portfolio_update_response(self, message): self.request_futures["process_agr_portfolio_update_response"].set_result(message) - def pre_process_d_prognosis_response(self, message): - self.request_futures["pre_process_d_prognosis_response"].set_result(message) - return self.response_futures["pre_process_d_prognosis_response"].result() - def process_d_prognosis_response(self, message): self.request_futures["process_d_prognosis_response"].set_result(message) - def pre_process_flex_offer_response(self, message): - self.request_futures["pre_process_flex_offer_response"].set_result(message) - return self.response_futures["pre_process_flex_offer_response"].result() - def process_flex_offer_response(self, message): self.request_futures["process_flex_offer_response"].set_result(message) - def pre_process_metering_response(self, message): - self.request_futures["pre_process_metering_response"].set_result(message) - return self.response_futures["pre_process_metering_response"].result() - def process_metering_response(self, message): self.request_futures["process_metering_response"].set_result(message) @@ -175,32 +132,16 @@ def __init__(self): ] } - def pre_process_agr_portfolio_query(self, message): - logger.info("Dummy Service: got AGR Portfolio Query") - self.request_futures["pre_process_agr_portfolio_query"].set_result(message) - logger.info("Dummy CRO Service: waiting for result") - return self.response_futures["pre_process_agr_portfolio_query"].result() - def process_agr_portfolio_query(self, message): self.request_futures["process_agr_portfolio_query"].set_result(message) - def pre_process_agr_portfolio_update(self, message): - self.request_futures["pre_process_agr_portfolio_update"].set_result(message) - return self.response_futures["pre_process_agr_portfolio_update"].result() def process_agr_portfolio_update(self, message): self.request_futures["process_agr_portfolio_update"].set_result(message) - def pre_process_dso_portfolio_query(self, message): - self.request_futures["pre_process_dso_portfolio_query"].set_result(message) - return self.response_futures["pre_process_dso_portfolio_query"].result() - def process_dso_portfolio_query(self, message): self.request_futures["process_dso_portfolio_query"].set_result(message) - def pre_process_dso_portfolio_update(self, message): - self.request_futures["pre_process_dso_portfolio_update"].set_result(message) - return self.response_futures["pre_process_dso_portfolio_update"].result() def process_dso_portfolio_update(self, message): self.request_futures["process_dso_portfolio_update"].set_result(message) @@ -239,73 +180,33 @@ def __init__(self): ] } - def pre_process_flex_offer(self, message): - self.request_futures["pre_process_flex_offer"].set_result(message) - return self.response_futures["pre_process_flex_offer"].result() - def process_flex_offer(self, message): self.request_futures["process_flex_offer"].set_result(message) - def pre_process_flex_order_response(self, message): - self.request_futures["pre_process_flex_order_response"].set_result(message) - return self.response_futures["pre_process_flex_order_response"].result() - def process_flex_order_response(self, message): self.request_futures["process_flex_order_response"].set_result(message) - def pre_process_d_prognosis(self, message): - self.request_futures["pre_process_d_prognosis"].set_result(message) - return self.response_futures["pre_process_d_prognosis"].result() - def process_d_prognosis(self, message): self.request_futures["process_d_prognosis"].set_result(message) - def pre_process_flex_offer_revocation(self, message): - self.request_futures["pre_process_flex_offer_revocation"].set_result(message) - return self.response_futures["pre_process_flex_offer_revocation"].result() - def process_flex_offer_revocation(self, message): self.request_futures["process_flex_offer_revocation"].set_result(message) - def pre_process_flex_settlement_response(self, message): - self.request_futures["pre_process_flex_settlement_response"].set_result(message) - return self.response_futures["pre_process_flex_settlement_response"].result() - def process_flex_settlement_response(self, message): self.request_futures["process_flex_settlement_response"].set_result(message) - def pre_process_dso_portfolio_update_response(self, message): - self.request_futures["pre_process_dso_portfolio_update_response"].set_result(message) - return self.response_futures["pre_process_dso_portfolio_update_response"].result() - def process_dso_portfolio_update_response(self, message): self.request_futures["process_dso_portfolio_update_response"].set_result(message) - def pre_process_dso_portfolio_query_response(self, message): - self.request_futures["pre_process_dso_portfolio_query_response"].set_result(message) - return self.response_futures["pre_process_dso_portfolio_query_response"].result() - def process_dso_portfolio_query_response(self, message): self.request_futures["process_dso_portfolio_query_response"].set_result(message) - def pre_process_flex_request_response(self, message): - self.request_futures["pre_process_flex_request_response"].set_result(message) - return self.response_futures["pre_process_flex_request_response"].result() - def process_flex_request_response(self, message): self.request_futures["process_flex_request_response"].set_result(message) - def pre_process_flex_reservation_update_response(self, message): - self.request_futures["pre_process_flex_reservation_update_response"].set_result(message) - return self.response_futures["pre_process_flex_reservation_update_response"].result() - def process_flex_reservation_update_response(self, message): self.request_futures["process_flex_reservation_update_response"].set_result(message) - def pre_process_metering(self, message): - self.request_futures["pre_process_metering"].set_result(message) - return self.response_futures["pre_process_metering"].result() - def process_metering(self, message): self.request_futures["process_metering"].set_result(message) diff --git a/test/test_client_errors.py b/test/test_client_errors.py index c920695..6c58227 100644 --- a/test/test_client_errors.py +++ b/test/test_client_errors.py @@ -1,11 +1,13 @@ +from unittest.mock import patch + import pytest +from nacl.bindings import crypto_sign_keypair + from shapeshifter_uftp.exceptions import ClientTransportException, SchemaException -from .helpers.services import DummyAgrService, DummyDsoService -from .helpers.messages import messages_by_type from shapeshifter_uftp.uftp import FlexRequestResponse -from unittest.mock import patch -from nacl.bindings import crypto_sign_keypair +from .helpers.messages import messages_by_type +from .helpers.services import DummyAgrService, DummyDsoService fake_keypair = crypto_sign_keypair() @@ -16,17 +18,3 @@ def test_send_non_payload_message(): client = service.dso_client(dso_service.sender_domain) with pytest.raises(TypeError): client._send_message("Hello there") - - -def test_non_200_status_code(): - def fake_pre_process_flex_request_response(self, message): - raise SchemaException() - with DummyDsoService() as dso_service: - with patch.object(dso_service, 'pre_process_flex_request_response', new=fake_pre_process_flex_request_response): - dso_service.pre_process_flex_request_response = fake_pre_process_flex_request_response - agr_service = DummyAgrService() - with agr_service.dso_client(dso_service.sender_domain) as client: - with pytest.raises(ClientTransportException) as err: - client.send_flex_request_response(messages_by_type[FlexRequestResponse]) - assert err.response.status_code == SchemaException.http_status_code - diff --git a/test/test_client_with_workers.py b/test/test_client_with_workers.py index 4d1c5c4..4c29f9b 100644 --- a/test/test_client_with_workers.py +++ b/test/test_client_with_workers.py @@ -1,10 +1,13 @@ -from .helpers.services import DummyAgrService, DummyCroService -from .helpers.messages import messages_by_type -from functools import partial from concurrent.futures import Future -from shapeshifter_uftp.uftp import PayloadMessageResponse, AgrPortfolioUpdate +from functools import partial from time import sleep +from shapeshifter_uftp.uftp import AgrPortfolioUpdate, PayloadMessageResponse + +from .helpers.messages import messages_by_type +from .helpers.services import DummyAgrService, DummyCroService + + def callback(response, future): future.set_result(response) @@ -15,10 +18,8 @@ def test_client_with_workers(): message = messages_by_type[AgrPortfolioUpdate] main_future = Future() client._queue_message(message, partial(callback, future=main_future)) - assert cro_service.request_futures["pre_process_agr_portfolio_update"].result() == message - response_message = PayloadMessageResponse() - cro_service.response_futures["pre_process_agr_portfolio_update"].set_result(PayloadMessageResponse()) - assert isinstance(main_future.result(), PayloadMessageResponse) + result = main_future.result() + assert result is None def test_client_with_workers_retries(): with DummyAgrService() as agr_service: @@ -37,10 +38,7 @@ def test_client_with_workers_retries(): print("Left sleep") client.recipient_endpoint = old_endpoint_url - assert cro_service.request_futures["pre_process_agr_portfolio_update"].result() == message - response_message = PayloadMessageResponse() - cro_service.response_futures["pre_process_agr_portfolio_update"].set_result(PayloadMessageResponse()) - assert isinstance(main_future.result(), PayloadMessageResponse) + assert main_future.result() is None def test_client_with_workers_retries_never_finishes(): with DummyAgrService() as agr_service: @@ -57,13 +55,8 @@ def test_client_with_workers_retries_never_finishes(): sleep(2.0) - print("Left sleep") - client.recipient_endpoint = old_endpoint_url - - sleep(1.0) - assert not cro_service.request_futures["pre_process_agr_portfolio_update"].done() - + assert main_future.done() is False def test_client_with_workers_error_in_callback(): @@ -77,8 +70,4 @@ def faulty_callback(response, future): message = messages_by_type[AgrPortfolioUpdate] main_future = Future() client._queue_message(message, partial(faulty_callback, future=main_future)) - assert cro_service.request_futures["pre_process_agr_portfolio_update"].result() == message - response_message = PayloadMessageResponse() - cro_service.response_futures["pre_process_agr_portfolio_update"].set_result(PayloadMessageResponse()) - assert isinstance(main_future.result(), PayloadMessageResponse) - + assert main_future.result() is None diff --git a/test/test_communications.py b/test/test_communications.py index 522ff22..3cc660e 100644 --- a/test/test_communications.py +++ b/test/test_communications.py @@ -1,54 +1,54 @@ -import pytest -from time import sleep -from concurrent.futures import ThreadPoolExecutor - -from shapeshifter_uftp.uftp import routing_map, PayloadMessageResponse, AcceptedRejected -from shapeshifter_uftp.service.base_service import snake_case -from .helpers.messages import messages -from .helpers.services import DummyAgrService, DummyCroService, DummyDsoService - - -# These fixtures allow us to only start up the services once and use -# them for all the parametrized test cases, which speeds up testing. -@pytest.fixture(scope='module') -def agr_service(): - with DummyAgrService() as service: - yield service - -@pytest.fixture(scope='module') -def cro_service(): - with DummyCroService() as service: - yield service - -@pytest.fixture(scope='module') -def dso_service(): - with DummyDsoService() as service: - yield service - - -@pytest.mark.parametrize( - 'message', - messages, - ids=[message.__class__.__name__ for message in messages] -) -def test_communications(message, agr_service, cro_service, dso_service): - service_map = { - "AGR": agr_service, - "CRO": cro_service, - "DSO": dso_service, - } - - sender_role, recipient_role = routing_map[type(message)] - sender = service_map[sender_role] - recipient = service_map[recipient_role] - client_method = f"{recipient.sender_role.lower()}_client" - with ThreadPoolExecutor() as executor: - with getattr(sender, client_method)(recipient.sender_domain) as client: - sending_method = f"send_{snake_case(message.__class__.__name__)}" - main_future = executor.submit(getattr(client, sending_method), message) - - assert recipient.request_futures[f"pre_process_{snake_case(message.__class__.__name__)}"].result() == message - response_message = PayloadMessageResponse() - recipient.response_futures[f"pre_process_{snake_case(message.__class__.__name__)}"].set_result(response_message) - assert recipient.request_futures[f"process_{snake_case(message.__class__.__name__)}"].result() == message - assert main_future.result().result == AcceptedRejected.ACCEPTED +from concurrent.futures import ThreadPoolExecutor +from time import sleep + +import pytest + +from shapeshifter_uftp.service.base_service import snake_case +from shapeshifter_uftp.uftp import AcceptedRejected, PayloadMessageResponse, routing_map + +from .helpers.messages import messages +from .helpers.services import DummyAgrService, DummyCroService, DummyDsoService + + +# These fixtures allow us to only start up the services once and use +# them for all the parametrized test cases, which speeds up testing. +@pytest.fixture(scope='module') +def agr_service(): + with DummyAgrService() as service: + yield service + +@pytest.fixture(scope='module') +def cro_service(): + with DummyCroService() as service: + yield service + +@pytest.fixture(scope='module') +def dso_service(): + with DummyDsoService() as service: + yield service + + +@pytest.mark.parametrize( + 'message', + messages, + ids=[message.__class__.__name__ for message in messages] +) +def test_communications(message, agr_service, cro_service, dso_service): + service_map = { + "AGR": agr_service, + "CRO": cro_service, + "DSO": dso_service, + } + + sender_role, recipient_role = routing_map[type(message)] + sender = service_map[sender_role] + recipient = service_map[recipient_role] + client_method = f"{recipient.sender_role.lower()}_client" + with ThreadPoolExecutor() as executor: + with getattr(sender, client_method)(recipient.sender_domain) as client: + sending_method = f"send_{snake_case(message.__class__.__name__)}" + main_future = executor.submit(getattr(client, sending_method), message) + + response_message = PayloadMessageResponse() + assert recipient.request_futures[f"process_{snake_case(message.__class__.__name__)}"].result() == message + assert main_future.result() is None diff --git a/test/test_default_responses.py b/test/test_default_responses.py index a081e14..b988480 100644 --- a/test/test_default_responses.py +++ b/test/test_default_responses.py @@ -1,9 +1,14 @@ import pytest -from shapeshifter_uftp.uftp import routing_map, PayloadMessageResponse, AcceptedRejected from shapeshifter_uftp.service.base_service import snake_case +from shapeshifter_uftp.uftp import AcceptedRejected, PayloadMessageResponse, routing_map + from .helpers.messages import messages -from .helpers.services import DefaultResponseAgrService, DefaultResponseCroService, DefaultResponseDsoService +from .helpers.services import ( + DefaultResponseAgrService, + DefaultResponseCroService, + DefaultResponseDsoService, +) # These fixtures allow us to only start up the services once and use @@ -47,5 +52,4 @@ def test_default_responses(message, default_agr_service, default_cro_service, de client = getattr(sender, client_method)(recipient.sender_domain) response = getattr(client, sending_method)(message) - assert type(response) == PayloadMessageResponse - assert response.result == AcceptedRejected.ACCEPTED + assert response is None diff --git a/test/test_presence_of_service_methods.py b/test/test_presence_of_service_methods.py index a5b37cc..54949e8 100644 --- a/test/test_presence_of_service_methods.py +++ b/test/test_presence_of_service_methods.py @@ -1,13 +1,20 @@ -import pytest import itertools + +import pytest + +from shapeshifter_uftp import ( + ShapeshifterAgrService, + ShapeshifterCroService, + ShapeshifterDsoService, +) from shapeshifter_uftp.service.base_service import snake_case -from shapeshifter_uftp import ShapeshifterAgrService, ShapeshifterDsoService, ShapeshifterCroService + @pytest.mark.parametrize('service,message_type,stage', [ - *itertools.product([ShapeshifterAgrService], ShapeshifterAgrService.acceptable_messages, ['pre_process', 'process']), - *itertools.product([ShapeshifterCroService], ShapeshifterCroService.acceptable_messages, ['pre_process', 'process']), - *itertools.product([ShapeshifterDsoService], ShapeshifterDsoService.acceptable_messages, ['pre_process', 'process']), + *itertools.product([ShapeshifterAgrService], ShapeshifterAgrService.acceptable_messages, ['process']), + *itertools.product([ShapeshifterCroService], ShapeshifterCroService.acceptable_messages, ['process']), + *itertools.product([ShapeshifterDsoService], ShapeshifterDsoService.acceptable_messages, ['process']), ] ) def test_presence_of_processing_methods(service, message_type, stage): diff --git a/test/test_service_errors.py b/test/test_service_errors.py index c01cffd..11103a5 100644 --- a/test/test_service_errors.py +++ b/test/test_service_errors.py @@ -1,19 +1,22 @@ -from .helpers.services import DummyAgrService, DummyCroService -from .helpers.messages import messages_by_type -from shapeshifter_uftp.uftp import FlexRequestResponse, AcceptedRejected, SignedMessage, AgrPortfolioUpdate, PayloadMessageResponse -from shapeshifter_uftp.transport import seal_message, unseal_message, to_xml, from_xml +from base64 import b64decode, b64encode + +import pytest import requests from nacl.bindings import crypto_sign -from base64 import b64encode, b64decode +from shapeshifter_uftp.exceptions import ClientTransportException +from shapeshifter_uftp.transport import from_xml, seal_message, to_xml, unseal_message +from shapeshifter_uftp.uftp import ( + AcceptedRejected, + AgrPortfolioUpdate, + FlexOffer, + FlexRequestResponse, + PayloadMessageResponse, + SignedMessage, +) -def test_unacceptable_message(): - with DummyCroService() as cro_service: - agr_service = DummyAgrService() - with agr_service.cro_client(cro_service.sender_domain) as client: - response = client._send_message(messages_by_type[FlexRequestResponse]) - assert response.result == AcceptedRejected.REJECTED - assert response.rejection_reason == "Invalid Message: 'FlexRequestResponse'" +from .helpers.messages import messages_by_type +from .helpers.services import DummyAgrService, DummyCroService def test_sender_mismatch(): @@ -21,8 +24,10 @@ def test_sender_mismatch(): Send a message with mismatching sender_domain in the outer envelope and the inner PayloadMessage. """ - with DummyCroService() as cro_service: - agr_service = DummyAgrService() + with ( + DummyCroService() as cro_service, + DummyAgrService() as agr_service + ): with agr_service.cro_client(cro_service.sender_domain) as client: message = messages_by_type[AgrPortfolioUpdate] message.sender_domain = "fake.domain" @@ -40,10 +45,8 @@ def test_sender_mismatch(): headers={"Content-Type": "text/xml"}, data=to_xml(signed_message) ) - assert response.status_code == 200 - sealed_response_message = from_xml(response.content) - unsealed_response_message = unseal_message(sealed_response_message.body, client.recipient_signing_key) + unsealed_response_message = agr_service.request_futures["process_agr_portfolio_update_response"].result(timeout=10) assert unsealed_response_message.result == AcceptedRejected.REJECTED assert unsealed_response_message.rejection_reason == 'Invalid Sender' @@ -71,16 +74,3 @@ def test_transport_error(): ) assert response.status_code == 400 - - -def test_error_during_post_process(): - def faulty_post_process(self, message): - raise ValueError("BOOM") - - with DummyCroService() as cro_service: - agr_service = DummyAgrService() - cro_service.process_agr_portfolio_update = faulty_post_process - cro_service.response_futures["pre_process_agr_portfolio_update"].set_result(PayloadMessageResponse()) - with agr_service.cro_client(cro_service.sender_domain) as client: - result = client.send_agr_portfolio_update(messages_by_type[AgrPortfolioUpdate]) - assert result.result == AcceptedRejected.ACCEPTED From 3a6e0a8fc4a71d26053c8d6d11ed2c888758689a Mon Sep 17 00:00:00 2001 From: Stan Janssen Date: Wed, 21 May 2025 08:47:55 +0200 Subject: [PATCH 2/5] More useful warnings when decoding messages. --- shapeshifter_uftp/transport.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/shapeshifter_uftp/transport.py b/shapeshifter_uftp/transport.py index f63a1d8..3c1fecd 100644 --- a/shapeshifter_uftp/transport.py +++ b/shapeshifter_uftp/transport.py @@ -58,13 +58,21 @@ def unseal_message(message: bytes, public_key: str) -> PayloadMessage: The message will be returned as a PayloadMessage object. """ + if public_key is None: + logger.warning( + "When calling unseal_message, no public key was provided. " + "Please check that your key_lookup function returns a key." + ) + raise TypeError("'public_key' must be of type 'str', not None") try: unsealed_message = crypto_sign_open(message, b64decode(public_key)) - logger.debug(f"Incoming Message: {unsealed_message.decode()}") + logger.debug(f"Incoming Message: {unsealed_message.decode('utf-8')}") return from_xml(unsealed_message) except BadSignatureError as exc: + logger.warning(f"The XML Signature for message {message} does not match the public key {public_key}: {exc}.") raise InvalidSignatureException() from exc except (ParserError, TypeError, ValueError) as exc: + logger.warning(f"The incoming XML Message {message} does not conform to the XML schema: {exc}.") raise SchemaException(str(exc)) from exc From 63c440ec81975a9c1f539bb0eae51048a28c1835 Mon Sep 17 00:00:00 2001 From: Stan Janssen Date: Wed, 21 May 2025 09:55:47 +0200 Subject: [PATCH 3/5] Add OAuth support for outgoing messages --- shapeshifter_uftp/__init__.py | 287 +++++++++++----------- shapeshifter_uftp/client/base_client.py | 38 ++- shapeshifter_uftp/oauth.py | 82 +++++++ shapeshifter_uftp/service/base_service.py | 12 +- test/helpers/services.py | 5 +- test/test_oauth.py | 129 ++++++++++ 6 files changed, 394 insertions(+), 159 deletions(-) create mode 100644 shapeshifter_uftp/oauth.py create mode 100644 test/test_oauth.py diff --git a/shapeshifter_uftp/__init__.py b/shapeshifter_uftp/__init__.py index 6f0bf4a..1ba54e3 100644 --- a/shapeshifter_uftp/__init__.py +++ b/shapeshifter_uftp/__init__.py @@ -1,143 +1,144 @@ -from .client import ( - ShapeshifterAgrCroClient, - ShapeshifterAgrDsoClient, - ShapeshifterCroAgrClient, - ShapeshifterCroDsoClient, - ShapeshifterDsoAgrClient, - ShapeshifterDsoCroClient, -) -from .service import ( - ShapeshifterAgrService, - ShapeshifterCroService, - ShapeshifterDsoService, -) -from .uftp import ( - AcceptedRejected, - AgrPortfolioQuery, - AgrPortfolioQueryResponse, - AgrPortfolioQueryResponseCongestionPoint, - AgrPortfolioQueryResponseConnection, - AgrPortfolioQueryResponseDSOPortfolio, - AgrPortfolioQueryResponseDSOView, - AgrPortfolioUpdate, - AgrPortfolioUpdateConnection, - AgrPortfolioUpdateResponse, - ContractSettlement, - ContractSettlementISP, - ContractSettlementPeriod, - DPrognosis, - DPrognosisISP, - DPrognosisResponse, - DsoPortfolioQuery, - DsoPortfolioQueryCongestionPoint, - DsoPortfolioQueryConnection, - DsoPortfolioQueryResponse, - DsoPortfolioUpdate, - DsoPortfolioUpdateCongestionPoint, - DsoPortfolioUpdateConnection, - DsoPortfolioUpdateResponse, - FlexMessage, - FlexOffer, - FlexOfferOption, - FlexOfferOptionISP, - FlexOfferResponse, - FlexOfferRevocation, - FlexOfferRevocationResponse, - FlexOrder, - FlexOrderISP, - FlexOrderResponse, - FlexOrderSettlement, - FlexOrderSettlementISP, - FlexOrderSettlementStatus, - FlexOrderStatus, - FlexRequest, - FlexRequestISP, - FlexRequestResponse, - FlexReservationUpdate, - FlexReservationUpdateISP, - FlexReservationUpdateResponse, - FlexSettlement, - FlexSettlementResponse, - Metering, - MeteringISP, - MeteringProfile, - MeteringProfileEnum, - MeteringResponse, - MeteringUnit, - PayloadMessage, - PayloadMessageResponse, - SignedMessage, - TestMessage, - TestMessageResponse, - UsefRole, -) - -__all__ = [ - "ShapeshifterAgrCroClient", - "ShapeshifterAgrDsoClient", - "ShapeshifterCroAgrClient", - "ShapeshifterCroDsoClient", - "ShapeshifterDsoAgrClient", - "ShapeshifterDsoCroClient", - "ShapeshifterAgrService", - "ShapeshifterDsoService", - "ShapeshifterCroService", - "AcceptedRejected", - "AgrPortfolioQuery", - "AgrPortfolioQueryResponse", - "AgrPortfolioQueryResponseCongestionPoint", - "AgrPortfolioQueryResponseConnection", - "AgrPortfolioQueryResponseDSOPortfolio", - "AgrPortfolioQueryResponseDSOView", - "AgrPortfolioUpdate", - "AgrPortfolioUpdateConnection", - "AgrPortfolioUpdateResponse", - "ContractSettlement", - "ContractSettlementISP", - "ContractSettlementPeriod", - "DPrognosis", - "DPrognosisISP", - "DPrognosisResponse", - "DsoPortfolioQuery", - "DsoPortfolioQueryCongestionPoint", - "DsoPortfolioQueryConnection", - "DsoPortfolioQueryResponse", - "DsoPortfolioUpdate", - "DsoPortfolioUpdateCongestionPoint", - "DsoPortfolioUpdateConnection", - "DsoPortfolioUpdateResponse", - "FlexMessage", - "FlexOffer", - "FlexOfferOption", - "FlexOfferOptionISP", - "FlexOfferResponse", - "FlexOfferRevocation", - "FlexOfferRevocationResponse", - "FlexOrder", - "FlexOrderISP", - "FlexOrderResponse", - "FlexOrderSettlement", - "FlexOrderSettlementISP", - "FlexOrderSettlementStatus", - "FlexOrderStatus", - "FlexRequest", - "FlexRequestISP", - "FlexRequestResponse", - "FlexReservationUpdate", - "FlexReservationUpdateISP", - "FlexReservationUpdateResponse", - "FlexSettlement", - "FlexSettlementResponse", - "Metering", - "MeteringISP", - "MeteringProfile", - "MeteringProfileEnum", - "MeteringResponse", - "MeteringUnit", - "PayloadMessage", - "PayloadMessageResponse", - "SignedMessage", - "TestMessage", - "TestMessageResponse", - "UsefRole", -] +from .client import ( + ShapeshifterAgrCroClient, + ShapeshifterAgrDsoClient, + ShapeshifterCroAgrClient, + ShapeshifterCroDsoClient, + ShapeshifterDsoAgrClient, + ShapeshifterDsoCroClient, +) +from .oauth import OAuthClient +from .service import ( + ShapeshifterAgrService, + ShapeshifterCroService, + ShapeshifterDsoService, +) +from .uftp import ( + AcceptedRejected, + AgrPortfolioQuery, + AgrPortfolioQueryResponse, + AgrPortfolioQueryResponseCongestionPoint, + AgrPortfolioQueryResponseConnection, + AgrPortfolioQueryResponseDSOPortfolio, + AgrPortfolioQueryResponseDSOView, + AgrPortfolioUpdate, + AgrPortfolioUpdateConnection, + AgrPortfolioUpdateResponse, + ContractSettlement, + ContractSettlementISP, + ContractSettlementPeriod, + DPrognosis, + DPrognosisISP, + DPrognosisResponse, + DsoPortfolioQuery, + DsoPortfolioQueryCongestionPoint, + DsoPortfolioQueryConnection, + DsoPortfolioQueryResponse, + DsoPortfolioUpdate, + DsoPortfolioUpdateCongestionPoint, + DsoPortfolioUpdateConnection, + DsoPortfolioUpdateResponse, + FlexMessage, + FlexOffer, + FlexOfferOption, + FlexOfferOptionISP, + FlexOfferResponse, + FlexOfferRevocation, + FlexOfferRevocationResponse, + FlexOrder, + FlexOrderISP, + FlexOrderResponse, + FlexOrderSettlement, + FlexOrderSettlementISP, + FlexOrderSettlementStatus, + FlexOrderStatus, + FlexRequest, + FlexRequestISP, + FlexRequestResponse, + FlexReservationUpdate, + FlexReservationUpdateISP, + FlexReservationUpdateResponse, + FlexSettlement, + FlexSettlementResponse, + Metering, + MeteringISP, + MeteringProfile, + MeteringProfileEnum, + MeteringResponse, + MeteringUnit, + PayloadMessage, + PayloadMessageResponse, + SignedMessage, + TestMessage, + TestMessageResponse, + UsefRole, +) + +__all__ = [ + "ShapeshifterAgrCroClient", + "ShapeshifterAgrDsoClient", + "ShapeshifterCroAgrClient", + "ShapeshifterCroDsoClient", + "ShapeshifterDsoAgrClient", + "ShapeshifterDsoCroClient", + "ShapeshifterAgrService", + "ShapeshifterDsoService", + "ShapeshifterCroService", + "AcceptedRejected", + "AgrPortfolioQuery", + "AgrPortfolioQueryResponse", + "AgrPortfolioQueryResponseCongestionPoint", + "AgrPortfolioQueryResponseConnection", + "AgrPortfolioQueryResponseDSOPortfolio", + "AgrPortfolioQueryResponseDSOView", + "AgrPortfolioUpdate", + "AgrPortfolioUpdateConnection", + "AgrPortfolioUpdateResponse", + "ContractSettlement", + "ContractSettlementISP", + "ContractSettlementPeriod", + "DPrognosis", + "DPrognosisISP", + "DPrognosisResponse", + "DsoPortfolioQuery", + "DsoPortfolioQueryCongestionPoint", + "DsoPortfolioQueryConnection", + "DsoPortfolioQueryResponse", + "DsoPortfolioUpdate", + "DsoPortfolioUpdateCongestionPoint", + "DsoPortfolioUpdateConnection", + "DsoPortfolioUpdateResponse", + "FlexMessage", + "FlexOffer", + "FlexOfferOption", + "FlexOfferOptionISP", + "FlexOfferResponse", + "FlexOfferRevocation", + "FlexOfferRevocationResponse", + "FlexOrder", + "FlexOrderISP", + "FlexOrderResponse", + "FlexOrderSettlement", + "FlexOrderSettlementISP", + "FlexOrderSettlementStatus", + "FlexOrderStatus", + "FlexRequest", + "FlexRequestISP", + "FlexRequestResponse", + "FlexReservationUpdate", + "FlexReservationUpdateISP", + "FlexReservationUpdateResponse", + "FlexSettlement", + "FlexSettlementResponse", + "Metering", + "MeteringISP", + "MeteringProfile", + "MeteringProfileEnum", + "MeteringResponse", + "MeteringUnit", + "PayloadMessage", + "PayloadMessageResponse", + "SignedMessage", + "TestMessage", + "TestMessageResponse", + "UsefRole", +] diff --git a/shapeshifter_uftp/client/base_client.py b/shapeshifter_uftp/client/base_client.py index a09c367..004ebf0 100644 --- a/shapeshifter_uftp/client/base_client.py +++ b/shapeshifter_uftp/client/base_client.py @@ -10,6 +10,7 @@ from .. import transport from ..exceptions import ClientTransportException from ..logging import logger +from ..oauth import OAuthClient, PassthroughOAuthClient from ..uftp import PayloadMessage, PayloadMessageResponse, SignedMessage @@ -34,16 +35,18 @@ def __init__( recipient_domain: str, recipient_endpoint: str = None, recipient_signing_key: str = None, + oauth_client: OAuthClient = None, ): """ Shapeshifter client class that allows you to initiate messages to a different party. - :param sender_domain: your sender domain - :param signing_key: your private signing key - :param recipient_domain: the domain of the recipient - :param recipient_endpoint: the full http endpoint URL of the recipient. If omitted, - will look up the endpoint using DNS. - :recipient_signing_key: the public signing key of the recipient. If omitted, will - look up the signing key using DNS. + :param str sender_domain: your sender domain + :param str signing_key: your private signing key + :param str recipient_domain: the domain of the recipient + :param str recipient_endpoint: the full http endpoint URL of the recipient. If omitted, + will look up the endpoint using DNS. + :param str recipient_signing_key: the public signing key of the recipient. If omitted, will + look up the signing key using DNS. + :param OAuthClient oauth_client: Optional OAuth client instance for using oauth to authenticate outgoing messages. """ if recipient_domain is None and recipient_endpoint is None: raise ValueError( @@ -67,6 +70,11 @@ def __init__( self.scheduler_event = Event() self.scheduler_thread = None + if oauth_client: + self.oauth_client = oauth_client + else: + self.oauth_client = PassthroughOAuthClient() + def _send_message(self, message: PayloadMessage) -> PayloadMessageResponse: """ Perform an operation. This will take the message object, pack @@ -115,12 +123,16 @@ def _send_message(self, message: PayloadMessage) -> PayloadMessageResponse: logger.debug(serialized_message) # Send the request to the relevant endpoint - response = requests.post( - self.recipient_endpoint, - data=serialized_message, - headers={"Content-Type": "text/xml; charset=utf-8"}, - timeout=self.request_timeout, - ) + with self.oauth_client.ensure_authenticated(): + response = requests.post( + self.recipient_endpoint, + data=serialized_message, + headers={ + "Content-Type": "text/xml; charset=utf-8", + **self.oauth_client.auth_header + }, + timeout=self.request_timeout, + ) if response.status_code != 200: error_msg = ( f"Request to {self.recipient_endpoint} was not succesful: " diff --git a/shapeshifter_uftp/oauth.py b/shapeshifter_uftp/oauth.py new file mode 100644 index 0000000..3e5054f --- /dev/null +++ b/shapeshifter_uftp/oauth.py @@ -0,0 +1,82 @@ +from contextlib import contextmanager +from datetime import datetime +from json import JSONDecodeError + +import requests + + +class OAuthClient: + + EXPIRATION_SAFETY_BUFFER = 60 + + def __init__(self, url, client_id, client_secret): + self.url = url + self.client_id = client_id + self.client_secret = client_secret + self.access_token = None + self.access_token_type = None + self.access_token_expiry = None + + @contextmanager + def ensure_authenticated(self): + if not self.authenticated: + self.authenticate() + yield + + @property + def authenticated(self): + return self.access_token and not self.expired + + @property + def expired(self): + return self.access_token_expiry < (datetime.now().timestamp() + OAuthClient.EXPIRATION_SAFETY_BUFFER) + + @property + def auth_header(self): + return {"Authorization": f"{self.access_token_type} {self.access_token}"} + + def authenticate(self): + response = requests.post( + self.url, + data={ + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + timeout=30 + ) + + if response.status_code != 200: + raise AuthorizationError( + f"Could not obtain an access token from the OAuth server at {self.url}:" + f"{response.text}" + ) + try: + response_data = response.json() + except JSONDecodeError as err: + raise AuthorizationError( + f"The OAuth server at {self.url} did not return a valid JSON response: " + f"{response.text}" + ) from err + + try: + self.access_token = response_data["access_token"] + self.access_token_type = response_data["token_type"] + self.access_token_expiry = datetime.now().timestamp() + response_data["expires_in"] + except KeyError as err: + raise AuthorizationError( + f"The response from the OAuth server is missing the {str(err)} field" + ) from err + + +class PassthroughOAuthClient: + + auth_header = {} + + @contextmanager + def ensure_authenticated(self): + yield + + +class AuthorizationError(Exception): + pass diff --git a/shapeshifter_uftp/service/base_service.py b/shapeshifter_uftp/service/base_service.py index c0dd2ff..c20819a 100644 --- a/shapeshifter_uftp/service/base_service.py +++ b/shapeshifter_uftp/service/base_service.py @@ -49,6 +49,7 @@ def __init__( signing_key, key_lookup_function=None, endpoint_lookup_function=None, + oauth_lookup_function=None, host: str = "0.0.0.0", port: int = 8080, path="/shapeshifter/api/v3/message", @@ -62,6 +63,9 @@ def __init__( :param key_lookup_function: A callable that takes a (sender_domain, sender_role) pair and returns a full endpoint URL (str). Omit parameter to use DNS for endpoint lookup. + :param oauth_lookup_function: A callable that takes a (sender_domain, sender_role) + pair and returns in instance of shapeshifter_uftp.OAuthClient + if OAuth authentication is required. :param host: the host to bind the server to (usually 127.0.0.1 or 0.0.0.0) :param port: the port to bind the server to (default: 8080) :param path: the URL path that the server listens on (default: /shapeshifter/api/v3/message) @@ -85,6 +89,10 @@ def __init__( # using well-known DNS names. self.endpoint_lookup_function = endpoint_lookup_function or transport.get_endpoint + # The OAuth lookup function is used to get the OAuth instance + # used to authenticate outgoing requests. + self.oauth_lookup_function = oauth_lookup_function + # The FastAPI web app takes care of routing messages to the # (one) endpoint, and by virtue of FastAPI-XML convert the # python-friendly objects into XML and vice versa. @@ -214,12 +222,14 @@ def _get_client(self, recipient_domain, recipient_role): client_cls = client_map[(self.sender_role, recipient_role)] recipient_endpoint = self.endpoint_lookup_function(recipient_domain, recipient_role) recipient_signing_key = self.key_lookup_function(recipient_domain, recipient_role) + oauth_client = self.oauth_lookup_function(recipient_domain, recipient_role) if self.oauth_lookup_function else None return client_cls( sender_domain = self.sender_domain, signing_key = self.signing_key, recipient_domain = recipient_domain, recipient_endpoint = recipient_endpoint, - recipient_signing_key = recipient_signing_key + recipient_signing_key = recipient_signing_key, + oauth_client = oauth_client, ) def _reject_message(self, message, unsealed_message, reason): diff --git a/test/helpers/services.py b/test/helpers/services.py index a4e75cf..3e2d96e 100644 --- a/test/helpers/services.py +++ b/test/helpers/services.py @@ -2,6 +2,7 @@ from base64 import b64decode, b64encode from concurrent.futures import Future +import responses from nacl.bindings import crypto_sign_keypair from shapeshifter_uftp import ( @@ -45,12 +46,13 @@ def key_lookup_function(domain, role): class DummyAgrService(ShapeshifterAgrService): - def __init__(self): + def __init__(self, oauth_lookup_function=None): super().__init__( sender_domain=AGR_DOMAIN, signing_key=AGR_PRIVATE_KEY, key_lookup_function=key_lookup_function, endpoint_lookup_function=endpoint_lookup_function, + oauth_lookup_function=oauth_lookup_function, port=AGR_TEST_PORT ) @@ -275,7 +277,6 @@ def process_dso_portfolio_update(self, message): pass - class DefaultResponseDsoService(ShapeshifterDsoService): def __init__(self): super().__init__( diff --git a/test/test_oauth.py b/test/test_oauth.py new file mode 100644 index 0000000..a461959 --- /dev/null +++ b/test/test_oauth.py @@ -0,0 +1,129 @@ +from datetime import datetime + +import pytest +import responses + +from shapeshifter_uftp import FlexOffer, OAuthClient, ShapeshifterAgrDsoClient +from shapeshifter_uftp.oauth import AuthorizationError + +from .helpers.messages import messages_by_type +from .helpers.services import ( + AGR_PRIVATE_KEY, + AGR_PUBLIC_KEY, + DSO_PUBLIC_KEY, + DummyAgrService, +) + +OAUTH_URL = "https://oauth.dummy.server" +CLIENT_ID = "client-id" +CLIENT_SECRET = "client-secret" +ACCESS_TOKEN = "access-token" + +@pytest.fixture +def oauth_client(*args, **kwargs): + return OAuthClient( + url=OAUTH_URL, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + +@responses.activate +def test_oauth_client(oauth_client): + now = datetime.now().timestamp() + responses.add( + method=responses.POST, + url=OAUTH_URL, + status=200, + json={ + "access_token": ACCESS_TOKEN, + "token_type": "Bearer", + "expires_in": 300 + } + ) + + oauth_client.authenticate() + + assert oauth_client.access_token == ACCESS_TOKEN + assert oauth_client.access_token_type == "Bearer" + assert now + 300 < oauth_client.access_token_expiry < now + 301 + + +@responses.activate +def test_oauth_shapeshifter_client(oauth_client): + responses.add( + method=responses.POST, + url=OAUTH_URL, + status=200, + json={ + "access_token": ACCESS_TOKEN, + "token_type": "Bearer", + "expires_in": 300 + } + ) + + responses.add( + responses.POST, + "http://localhost:9003/shapeshifter/api/v3/message" + ) + + client = ShapeshifterAgrDsoClient( + sender_domain="agr.dev", + signing_key=AGR_PRIVATE_KEY, + recipient_domain="dso.dev", + recipient_endpoint="http://localhost:9003/shapeshifter/api/v3/message", + recipient_signing_key=DSO_PUBLIC_KEY, + oauth_client=oauth_client + ) + + response = client.send_flex_offer(messages_by_type[FlexOffer]) + assert response is None + + +@responses.activate +def test_oauth_shapeshifter_client_failed(oauth_client): + responses.add( + method=responses.POST, + url=OAUTH_URL, + status=400, + json={ + "error": "invalid_request", + "error_description": "Could not process" + } + ) + + responses.add( + responses.POST, + "http://localhost:9003/shapeshifter/api/v3/message" + ) + + client = ShapeshifterAgrDsoClient( + sender_domain="agr.dev", + signing_key=AGR_PRIVATE_KEY, + recipient_domain="dso.dev", + recipient_endpoint="http://localhost:9003/shapeshifter/api/v3/message", + recipient_signing_key=DSO_PUBLIC_KEY, + oauth_client=oauth_client + ) + + with pytest.raises(AuthorizationError): + response = client.send_flex_offer(messages_by_type[FlexOffer]) + +@responses.activate +def test_oauth_shapeshifter_service(): + def oauth_lookup_function(sender_domain, sender_role): + return OAuthClient( + url=OAUTH_URL, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + ) + + service = DummyAgrService( + oauth_lookup_function=oauth_lookup_function + ) + + client = service.dso_client("dso.dev") + assert isinstance(client.oauth_client, OAuthClient) + assert client.oauth_client.url == OAUTH_URL + assert client.oauth_client.client_id == CLIENT_ID + assert client.oauth_client.client_secret == CLIENT_SECRET + From 5a3e5a0954a588b0282a72c7b30d4924559e14eb Mon Sep 17 00:00:00 2001 From: Stan Janssen Date: Mon, 26 May 2025 08:33:39 +0200 Subject: [PATCH 4/5] Convert setup.py to pyproject.toml --- pyproject.toml | 43 +++++++++++++++++++++++++++++++++++++++++++ setup.py | 41 ----------------------------------------- 2 files changed, 43 insertions(+), 41 deletions(-) delete mode 100644 setup.py diff --git a/pyproject.toml b/pyproject.toml index 9bebb7e..5be85a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,37 @@ +[project] +name = "shapeshifter_uftp" +version = "2.0.0b2" +dependencies = [ + "xsdata[lxml]>=24.4,<=25.4", + "pynacl==1.5.0", + "dnspython==2.6.1", + "fastapi>=0.110,<0.116", + "fastapi-xml==1.1.1", + "requests", + "uvicorn", + "termcolor" +] +requires-python = ">=3.10" + +[dependency-groups] +dev = [ + "xmlschema", + "pytest", + "pytest-cov", + "pylint", + "responses", + "sphinx", + "sphinx-rtd-theme" +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project.scripts] +shapeshifter-keypair = "shapeshifter_uftp.cli:generate_signing_keypair" +shapeshifter-lookup = "shapeshifter_uftp.cli:perform_lookup" + [tool.pytest.ini_options] addopts = "-v --cov --cov-report html --cov-report lcov" @@ -19,3 +53,12 @@ directory = "test_coverage" [tool.isort] profile = "black" + +[tool.setuptools] +packages = [ + "shapeshifter_uftp", + "shapeshifter_uftp.client", + "shapeshifter_uftp.service", + "shapeshifter_uftp.uftp", + "shapeshifter_uftp.uftp.messages" +] diff --git a/setup.py b/setup.py deleted file mode 100644 index fa0cc06..0000000 --- a/setup.py +++ /dev/null @@ -1,41 +0,0 @@ -from setuptools import setup - -setup( - name="shapeshifter-uftp", - version="1.3.0", - python_requires=">=3.10", - description="Allows connections between DSO, AGR and CRO using the Shapeshifter (UFTP) protocol.", - packages=[ - "shapeshifter_uftp", - "shapeshifter_uftp.client", - "shapeshifter_uftp.service", - "shapeshifter_uftp.uftp", - "shapeshifter_uftp.uftp.messages" - ], - install_requires=[ - "xsdata[lxml]>=24.4,<=24.7", - "pynacl==1.5.0", - "dnspython==2.6.1", - "fastapi>=0.110,<0.113", - "fastapi-xml==1.1.0", - "requests", - "uvicorn", - "termcolor", - ], - extras_require={ - "dev": [ - "xmlschema", - "pytest", - "pytest-cov", - "pylint", - "sphinx", - "sphinx-rtd-theme", - ] - }, - entry_points={ - "console_scripts": [ - "shapeshifter-keypair = shapeshifter_uftp.cli:generate_signing_keypair", - "shapeshifter-lookup = shapeshifter_uftp.cli:perform_lookup", - ] - }, -) From 807ff73fc8e2184262bdace0a7b73368f066a643 Mon Sep 17 00:00:00 2001 From: Stan Janssen Date: Mon, 26 May 2025 08:33:45 +0200 Subject: [PATCH 5/5] Release version 2.0.0 --- README.rst | 197 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 194 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 11de8e5..d9a22c4 100644 --- a/README.rst +++ b/README.rst @@ -20,12 +20,198 @@ Running tests $ pytest . -Documentation -------------- +Getting Started +--------------- + +Shapehifter always requires the use of a Client and a Service, because all responses are asynchronous. + +You choose the server class based on your role in the Shapeshifter conversation. If you are an Aggregator (also known as a CSP), you can use this setup: + +.. code-block:: python3 + + from datetime import datetime, timedelta, timezone + + from shapeshifter_uftp import ShapeshifterAgrService + from shapeshifter_uftp.uftp import (FlexOffer, FlexOfferOption, + FlexOfferOptionISP, FlexRequest, + FlexRequestResponse, FlexOrder, FlexOrderResponse, + AcceptedRejected) + from xsdata.models.datatype import XmlDate + + + class DemoAggregator(ShapeshifterAgrService): + """ + Aggregator service that implements callbacks for + each of the messages that can be received. + """ + + def process_agr_portfolio_query_response(self, message): + print(f"Received a message: {message}") + + def process_agr_portfolio_update_response(self, message): + print(f"Received a message: {message}") + + def process_d_prognosis_response(self, message): + print(f"Received a message: {message}") + + def process_flex_request(self, message: FlexRequest): + print(f"Received a message: {message}") + + # Example of how to send a new message after + # processing an incoming message. + dso_client = self.dso_client(message.sender_domain) + + # Send the FlexRequestResponse + dso_client.send_flex_request_response( + FlexRequestResponse( + flex_request_message_id=message.message_id, + conversation_id=message.conversation_id, + result=AcceptedRejected.ACCEPTED + ) + ) + + # Send the FlexOffer + dso_client.send_flex_offer( + FlexOffer( + flex_request_message_id=message.message_id, + conversation_id=message.conversation_id, + isp_duration="PT15M", + period=XmlDate(2023, 1, 1), + congestion_point="ean.123456789012", + expiration_date_time=datetime.now(timezone.utc).isoformat(), + offer_options=[ + FlexOfferOption( + isps=[FlexOfferOptionISP(power=1, start=1, duration=1)], + option_reference="MyOption", + price=2.30, + min_activation_factor=0.5, + ) + ], + ) + ) + + def process_flex_offer_response(self, message: FlexOffer): + print(f"Received a message: {message}") + + def process_flex_offer_revocation_response(self, message): + print(f"Received a message: {message}") + + def process_flex_order(self, message: FlexOrder): + print(f"Received a message: {message}") + + dso_client = self.dso_client(message.sender_domain) + dso_client.send_flex_order_response( + FlexOrderResponse( + flex_order_message_id=message.message_id, + conversation_id=message.conversation_id, + result=AcceptedRejected.ACCEPTED + ) + ) + + def process_flex_reservation_update(self, message): + print(f"Received a message: {message}") -You can read the documentation at readthedocs_. + def process_flex_settlement(self, message): + print(f"Received a message: {message}") -.. _readthedocs: https://shapeshifter-uftp.readthedocs.io + def process_metering_response(self, message): + print(f"Received a message: {message}") + + + def key_lookup(sender_domain, sender_role): + """ + Lookup function for public keys, so that incoming + messages can be verified. + """ + known_senders = { + ("dso.demo", "DSO"): "NsTbq/iABU6tbsjriBg/Z5dSfQstulD0GpMI2fLDWec=", + ("cro.demo", "CRO"): "ySUYU87usErRFKGJafwvVDLGhnBVJCCNYfQvmwv8ObM=", + } + return known_senders.get((sender_domain, sender_role)) + + + def endpoint_lookup(sender_domain, sender_role): + """ + Lookup function for endpoints, so that the service + knowns where to send responses to. + """ + known_senders = { + ("dso.demo", "DSO"): "http://localhost:8081/shapeshifter/api/v3/message", + ("cro.demo", "CRO"): "http://localhost:8082/shapeshifter/api/v3/message", + } + return known_senders.get((sender_domain, sender_role)) + + aggregator = DemoAggregator( + sender_domain="aggregator.demo", + signing_key="mz5XYCNKxpx48K+9oipUhsjBZed3L7rTVKLsWmG1HOqRLIeuGpIa1KAt6AlbVGqJvewd8v1J0uVUTqpGt7F8tw==", + key_lookup_function=key_lookup, + endpoint_lookup_function=endpoint_lookup, + port=8080, + ) + + # Start the Aggregator Service + aggregator.run_in_thread() + + # Create a client object to talk to a DSO + dso_client = aggregator.dso_client("dso.demo") + + # Create a Flex Offer Message + flex_offer_message = FlexOffer( + isp_duration="PT15M", + period=XmlDate(2023, 1, 1), + congestion_point="ean.123456789012", + expiration_date_time=datetime.now(timezone.utc).isoformat(), + flex_request_message_id=str(uuid4()) + offer_options=[ + FlexOfferOption( + isps=[FlexOfferOptionISP(power=1, start=1, duration=1)], + option_reference="MyOption", + price=2.30, + min_activation_factor=0.5, + ) + ], + ) + + # As a demo, press enter to send another FlexOffer message to the DSO. + while True: + try: + input("Press return to send a FlexOffer message to the DSO") + response = dso_client.send_flex_offer(flex_offer_message) + print(f"Response was: {response}") + except: + aggregator.stop() + break + +Using OAuth in outgoing requests +-------------------------------- + +To use OAuth in outgoing requests, you can use the provided OAuthClient class. To use it in a bare Shapeshifter client: + +.. code-block:: python3 + + from shapeshifter_uftp import ShapeshifterAgrDsoClient, OAuthClient + + oauth_client = OAuthClient( + url="https://oauth.provider.url", + client_id="my-client-id", + client_secret="my-client-secret" + ) + + client = ShapeshifterAgrDsoClient( + sender_domain="my.aggregator.domain", + signing_key="abcdef", + recipient_domain="some.dso", + recipient_endpoint="https://some.dso.endpoint/shapeshifter/api/v3/message", + recipient_signing_key="123456", + oauth_client=oauth_client, + ) + + # If you use any of the sending methods, the oauth client will + # make sure you're authenticated. + client.send_flex_request_response(...) + + +Similarly, if you have a Service instance that dynamically needs to retrieve the OAuth information for each different recipient server, you can provide an ``oauth_lookup_function`` that takes a ``(sender_domain, sender_role)`` and returns an instance of OAuthClient: Overview @@ -50,6 +236,9 @@ Version History +-------------+-------------------+----------------------------------+ | Version | Release Date | Release Notes | +=============+===================+==================================+ +| 2.0.0 | 2025-05-21 | Support for OAuth 2 on outgoing | +| | | messages, updated dependencies | ++-------------+-------------------+----------------------------------+ | 1.2.0 | 2024-04-04 | Upgrade to latest FastAPI and | | | | Pydantic. | +-------------+-------------------+----------------------------------+ diff --git a/pyproject.toml b/pyproject.toml index 5be85a7..5e7e5fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shapeshifter_uftp" -version = "2.0.0b2" +version = "2.0.0" dependencies = [ "xsdata[lxml]>=24.4,<=25.4", "pynacl==1.5.0",